Improve rTasks drag-drop UX + sync space members on invite claim
CI/CD / deploy (push) Failing after 9s
Details
CI/CD / deploy (push) Failing after 9s
Details
rTasks: port backlog-md ordinal algorithm (bisection + rebalance), fix column detection via bounding-box hit test, add empty-column drop zones, source column dimming, no-op detection, and optimistic DOM updates (no flash). New bulk-sort-order rebalance endpoint. EncryptID: sync claimed invite members to Automerge doc immediately, redirect to space subdomain after identity claim. Server: add /api/internal/sync-space-member endpoint, fallback member check in WebSocket auth for not-yet-synced invites. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
fef217798e
commit
d2fa533519
|
|
@ -44,6 +44,8 @@ services:
|
||||||
- SMTP_HOST=${SMTP_HOST:-mailcowdockerized-postfix-mailcow-1}
|
- SMTP_HOST=${SMTP_HOST:-mailcowdockerized-postfix-mailcow-1}
|
||||||
- SMTP_PORT=${SMTP_PORT:-587}
|
- SMTP_PORT=${SMTP_PORT:-587}
|
||||||
- SMTP_USER=${SMTP_USER:-noreply@rmail.online}
|
- SMTP_USER=${SMTP_USER:-noreply@rmail.online}
|
||||||
|
- SMTP_PASS=${SMTP_PASS}
|
||||||
|
- SMTP_FROM=${SMTP_FROM:-rSpace <noreply@rmail.online>}
|
||||||
- SITE_URL=https://rspace.online
|
- SITE_URL=https://rspace.online
|
||||||
- RTASKS_REPO_BASE=/repos
|
- RTASKS_REPO_BASE=/repos
|
||||||
- SPLAT_NOTIFY_EMAIL=jeffemmett@gmail.com
|
- SPLAT_NOTIFY_EMAIL=jeffemmett@gmail.com
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ class FolkTasksBoard extends HTMLElement {
|
||||||
private boardView: "board" | "checklist" = "board";
|
private boardView: "board" | "checklist" = "board";
|
||||||
private dragOverStatus: string | null = null;
|
private dragOverStatus: string | null = null;
|
||||||
private dragOverIndex = -1;
|
private dragOverIndex = -1;
|
||||||
|
private dragSourceStatus: string | null = null;
|
||||||
private priorities = ["LOW", "MEDIUM", "HIGH", "URGENT"];
|
private priorities = ["LOW", "MEDIUM", "HIGH", "URGENT"];
|
||||||
private _offlineUnsubs: (() => void)[] = [];
|
private _offlineUnsubs: (() => void)[] = [];
|
||||||
private _history = new ViewHistory<"list" | "board">("list");
|
private _history = new ViewHistory<"list" | "board">("list");
|
||||||
|
|
@ -481,16 +482,100 @@ class FolkTasksBoard extends HTMLElement {
|
||||||
} catch { this.error = "Failed to move task"; this.render(); }
|
} catch { this.error = "Failed to move task"; this.render(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
private computeSortOrder(targetStatus: string, insertIndex: number): number {
|
/** Optimistic drop — update local state immediately, persist in background */
|
||||||
|
private persistDrop(taskId: string, newStatus: string, sortOrder: number, rebalanceUpdates: { id: string; sort_order: number }[]) {
|
||||||
|
// Update local tasks array immediately
|
||||||
|
const task = this.tasks.find(t => t.id === taskId);
|
||||||
|
if (!task) return;
|
||||||
|
task.status = newStatus;
|
||||||
|
task.sort_order = sortOrder;
|
||||||
|
// Apply rebalance updates to local state
|
||||||
|
for (const u of rebalanceUpdates) {
|
||||||
|
const t = this.tasks.find(t => t.id === u.id);
|
||||||
|
if (t) t.sort_order = u.sort_order;
|
||||||
|
}
|
||||||
|
this.render();
|
||||||
|
|
||||||
|
if (this.isDemo) return;
|
||||||
|
|
||||||
|
// Fire-and-forget API calls
|
||||||
|
const base = this.getApiBase();
|
||||||
|
fetch(`${base}/api/tasks/${taskId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ status: newStatus, sort_order: sortOrder }),
|
||||||
|
}).catch(() => { this.error = "Failed to save task move"; this.render(); });
|
||||||
|
|
||||||
|
if (rebalanceUpdates.length > 0) {
|
||||||
|
fetch(`${base}/api/tasks/bulk-sort-order`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ updates: rebalanceUpdates }),
|
||||||
|
}).catch(() => {}); // non-critical
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Ordinal algorithm (ported from backlog-md) ──
|
||||||
|
|
||||||
|
private static readonly ORDINAL_STEP = 1000;
|
||||||
|
private static readonly ORDINAL_EPSILON = 1e-6;
|
||||||
|
|
||||||
|
private calculateNewOrdinal(
|
||||||
|
prev: { sort_order: number } | null,
|
||||||
|
next: { sort_order: number } | null,
|
||||||
|
): { ordinal: number; requiresRebalance: boolean } {
|
||||||
|
const STEP = FolkTasksBoard.ORDINAL_STEP;
|
||||||
|
const EPS = FolkTasksBoard.ORDINAL_EPSILON;
|
||||||
|
const p = prev?.sort_order;
|
||||||
|
const n = next?.sort_order;
|
||||||
|
|
||||||
|
if (p === undefined && n === undefined) return { ordinal: STEP, requiresRebalance: false };
|
||||||
|
if (p === undefined || p === null) {
|
||||||
|
const candidate = (n ?? STEP) / 2;
|
||||||
|
return { ordinal: candidate, requiresRebalance: !Number.isFinite(candidate) || candidate <= 0 || candidate >= (n ?? STEP) - EPS };
|
||||||
|
}
|
||||||
|
if (n === undefined || n === null) {
|
||||||
|
const candidate = p + STEP;
|
||||||
|
return { ordinal: candidate, requiresRebalance: !Number.isFinite(candidate) };
|
||||||
|
}
|
||||||
|
const gap = n - p;
|
||||||
|
if (gap <= EPS) return { ordinal: p + STEP, requiresRebalance: true };
|
||||||
|
const candidate = p + gap / 2;
|
||||||
|
return { ordinal: candidate, requiresRebalance: candidate <= p + EPS || candidate >= n - EPS };
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveOrdinalConflicts(tasks: any[]): { id: string; sort_order: number }[] {
|
||||||
|
const STEP = FolkTasksBoard.ORDINAL_STEP;
|
||||||
|
const updates: { id: string; sort_order: number }[] = [];
|
||||||
|
let last: number | undefined;
|
||||||
|
for (let i = 0; i < tasks.length; i++) {
|
||||||
|
const t = tasks[i];
|
||||||
|
const assigned = i === 0 ? STEP : (last ?? STEP) + STEP;
|
||||||
|
if (assigned !== (t.sort_order ?? 0)) updates.push({ id: t.id, sort_order: assigned });
|
||||||
|
t.sort_order = assigned;
|
||||||
|
last = assigned;
|
||||||
|
}
|
||||||
|
return updates;
|
||||||
|
}
|
||||||
|
|
||||||
|
private computeSortOrder(targetStatus: string, insertIndex: number): { sortOrder: number; rebalanceUpdates: { id: string; sort_order: number }[] } {
|
||||||
const columnTasks = this.tasks
|
const columnTasks = this.tasks
|
||||||
.filter(t => t.status === targetStatus && t.id !== this.dragTaskId)
|
.filter(t => t.status === targetStatus && t.id !== this.dragTaskId)
|
||||||
.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
|
.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
|
||||||
if (columnTasks.length === 0) return 0;
|
|
||||||
if (insertIndex <= 0) return (columnTasks[0].sort_order ?? 0) - 1000;
|
const prev = insertIndex > 0 ? columnTasks[insertIndex - 1] : null;
|
||||||
if (insertIndex >= columnTasks.length) return (columnTasks[columnTasks.length - 1].sort_order ?? 0) + 1000;
|
const next = insertIndex < columnTasks.length ? columnTasks[insertIndex] : null;
|
||||||
const before = columnTasks[insertIndex - 1].sort_order ?? 0;
|
const { ordinal, requiresRebalance } = this.calculateNewOrdinal(prev, next);
|
||||||
const after = columnTasks[insertIndex].sort_order ?? 0;
|
|
||||||
return (before + after) / 2;
|
let rebalanceUpdates: { id: string; sort_order: number }[] = [];
|
||||||
|
if (requiresRebalance) {
|
||||||
|
// Insert dragged task at correct position for rebalance
|
||||||
|
const all = [...columnTasks];
|
||||||
|
all.splice(insertIndex, 0, { id: this.dragTaskId, sort_order: ordinal });
|
||||||
|
rebalanceUpdates = this.resolveOrdinalConflicts(all);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sortOrder: ordinal, rebalanceUpdates };
|
||||||
}
|
}
|
||||||
|
|
||||||
private openBoard(slug: string) {
|
private openBoard(slug: string) {
|
||||||
|
|
@ -539,6 +624,7 @@ class FolkTasksBoard extends HTMLElement {
|
||||||
.col-count { background: var(--rs-border); border-radius: 10px; padding: 0 8px; font-size: 11px; }
|
.col-count { background: var(--rs-border); border-radius: 10px; padding: 0 8px; font-size: 11px; }
|
||||||
|
|
||||||
.column.drag-over { background: var(--rs-bg-surface); border-color: var(--rs-primary); }
|
.column.drag-over { background: var(--rs-bg-surface); border-color: var(--rs-primary); }
|
||||||
|
.column.drag-source { opacity: 0.55; }
|
||||||
|
|
||||||
.task-card {
|
.task-card {
|
||||||
background: var(--rs-bg-surface); border: 1px solid var(--rs-border-strong); border-radius: 8px;
|
background: var(--rs-bg-surface); border: 1px solid var(--rs-border-strong); border-radius: 8px;
|
||||||
|
|
@ -590,9 +676,13 @@ class FolkTasksBoard extends HTMLElement {
|
||||||
.view-toggle__btn:hover:not(.active) { color: var(--rs-text-secondary); }
|
.view-toggle__btn:hover:not(.active) { color: var(--rs-text-secondary); }
|
||||||
|
|
||||||
/* Drop indicator */
|
/* Drop indicator */
|
||||||
.drop-indicator { height: 2px; background: var(--rs-primary); border-radius: 1px; margin: 2px 0; animation: pulse-indicator 1.5s ease-in-out infinite; }
|
.drop-indicator { height: 3px; background: var(--rs-primary); border-radius: 2px; margin: 2px 0; pointer-events: none; animation: pulse-indicator 1.5s ease-in-out infinite; }
|
||||||
@keyframes pulse-indicator { 0%, 100% { opacity: 0.6; } 50% { opacity: 1; box-shadow: 0 0 8px var(--rs-primary); } }
|
@keyframes pulse-indicator { 0%, 100% { opacity: 0.6; } 50% { opacity: 1; box-shadow: 0 0 8px var(--rs-primary); } }
|
||||||
|
|
||||||
|
/* Empty column drop zone */
|
||||||
|
.empty-drop-zone { border: 2px dashed #22c55e; border-radius: 8px; padding: 24px 12px; text-align: center; color: #22c55e; font-size: 12px; font-weight: 600; pointer-events: none; opacity: 0; transition: opacity 0.15s; }
|
||||||
|
.column.drag-over .empty-drop-zone { opacity: 1; }
|
||||||
|
|
||||||
/* Checklist view */
|
/* Checklist view */
|
||||||
.checklist { max-width: 720px; }
|
.checklist { max-width: 720px; }
|
||||||
.checklist-group { margin-bottom: 16px; }
|
.checklist-group { margin-bottom: 16px; }
|
||||||
|
|
@ -746,14 +836,16 @@ class FolkTasksBoard extends HTMLElement {
|
||||||
const columnTasks = this.tasks
|
const columnTasks = this.tasks
|
||||||
.filter(t => t.status === status)
|
.filter(t => t.status === status)
|
||||||
.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
|
.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
|
||||||
|
const isDragSource = this.dragTaskId && this.dragSourceStatus === status;
|
||||||
return `
|
return `
|
||||||
<div class="column" data-status="${status}">
|
<div class="column${isDragSource ? ' drag-source' : ''}" data-status="${status}">
|
||||||
<div class="col-header">
|
<div class="col-header">
|
||||||
<span>${this.esc(status.replace(/_/g, " "))}</span>
|
<span>${this.esc(status.replace(/_/g, " "))}</span>
|
||||||
<span class="col-count">${columnTasks.length}</span>
|
<span class="col-count">${columnTasks.length}</span>
|
||||||
</div>
|
</div>
|
||||||
${status === "TODO" ? this.renderCreateForm() : ""}
|
${status === "TODO" ? this.renderCreateForm() : ""}
|
||||||
${columnTasks.map(t => this.renderTaskCard(t, status)).join("")}
|
${columnTasks.map(t => this.renderTaskCard(t, status)).join("")}
|
||||||
|
${columnTasks.length === 0 && status !== "TODO" ? '<div class="empty-drop-zone">Drop task here to change status</div>' : ''}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}).join("")}
|
}).join("")}
|
||||||
|
|
@ -990,6 +1082,9 @@ class FolkTasksBoard extends HTMLElement {
|
||||||
if (pe.button !== 0) return;
|
if (pe.button !== 0) return;
|
||||||
if (el.getAttribute("draggable") === "false") return;
|
if (el.getAttribute("draggable") === "false") return;
|
||||||
this.dragTaskId = el.dataset.taskId || null;
|
this.dragTaskId = el.dataset.taskId || null;
|
||||||
|
// Track source column for dimming
|
||||||
|
const task = this.tasks.find(t => t.id === this.dragTaskId);
|
||||||
|
this.dragSourceStatus = task?.status || null;
|
||||||
el.classList.add("dragging");
|
el.classList.add("dragging");
|
||||||
el.setPointerCapture(pe.pointerId);
|
el.setPointerCapture(pe.pointerId);
|
||||||
el.style.touchAction = "none";
|
el.style.touchAction = "none";
|
||||||
|
|
@ -999,14 +1094,30 @@ class FolkTasksBoard extends HTMLElement {
|
||||||
const pe = e as PointerEvent;
|
const pe = e as PointerEvent;
|
||||||
pe.preventDefault();
|
pe.preventDefault();
|
||||||
// Clear previous state
|
// Clear previous state
|
||||||
this.shadow.querySelectorAll(".column[data-status]").forEach(c => (c as HTMLElement).classList.remove("drag-over"));
|
this.shadow.querySelectorAll(".column[data-status]").forEach(c => {
|
||||||
|
(c as HTMLElement).classList.remove("drag-over");
|
||||||
|
// Re-apply drag-source class since we're clearing
|
||||||
|
if (this.dragSourceStatus && (c as HTMLElement).dataset.status === this.dragSourceStatus) {
|
||||||
|
(c as HTMLElement).classList.add("drag-source");
|
||||||
|
}
|
||||||
|
});
|
||||||
this.shadow.querySelector('.drop-indicator')?.remove();
|
this.shadow.querySelector('.drop-indicator')?.remove();
|
||||||
// Find target column
|
// Find target column via bounding-box hit test (fixes Shadow DOM elementFromPoint issues)
|
||||||
const target = this.shadow.elementFromPoint(pe.clientX, pe.clientY) as HTMLElement | null;
|
let targetCol: HTMLElement | null = null;
|
||||||
const targetCol = target?.closest?.(".column[data-status]") as HTMLElement | null;
|
const columns = this.shadow.querySelectorAll(".column[data-status]");
|
||||||
|
for (const col of columns) {
|
||||||
|
const rect = col.getBoundingClientRect();
|
||||||
|
if (pe.clientX >= rect.left && pe.clientX <= rect.right && pe.clientY >= rect.top && pe.clientY <= rect.bottom) {
|
||||||
|
targetCol = col as HTMLElement;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (targetCol) {
|
if (targetCol) {
|
||||||
targetCol.classList.add("drag-over");
|
// Don't highlight source column green — it's already dimmed
|
||||||
// Calculate insert position
|
if (targetCol.dataset.status !== this.dragSourceStatus) {
|
||||||
|
targetCol.classList.add("drag-over");
|
||||||
|
}
|
||||||
|
// Calculate insert position among non-dragging cards
|
||||||
const cards = Array.from(targetCol.querySelectorAll('.task-card:not(.dragging)'));
|
const cards = Array.from(targetCol.querySelectorAll('.task-card:not(.dragging)'));
|
||||||
let insertIndex = cards.length;
|
let insertIndex = cards.length;
|
||||||
for (let i = 0; i < cards.length; i++) {
|
for (let i = 0; i < cards.length; i++) {
|
||||||
|
|
@ -1020,9 +1131,11 @@ class FolkTasksBoard extends HTMLElement {
|
||||||
indicator.className = 'drop-indicator';
|
indicator.className = 'drop-indicator';
|
||||||
if (insertIndex < cards.length) {
|
if (insertIndex < cards.length) {
|
||||||
cards[insertIndex].before(indicator);
|
cards[insertIndex].before(indicator);
|
||||||
} else {
|
} else if (cards.length > 0) {
|
||||||
targetCol.appendChild(indicator);
|
// After last card
|
||||||
|
cards[cards.length - 1].after(indicator);
|
||||||
}
|
}
|
||||||
|
// For empty columns the empty-drop-zone CSS handles the visual
|
||||||
} else {
|
} else {
|
||||||
this.dragOverStatus = null;
|
this.dragOverStatus = null;
|
||||||
this.dragOverIndex = -1;
|
this.dragOverIndex = -1;
|
||||||
|
|
@ -1032,29 +1145,44 @@ class FolkTasksBoard extends HTMLElement {
|
||||||
if (!this.dragTaskId) return;
|
if (!this.dragTaskId) return;
|
||||||
el.classList.remove("dragging");
|
el.classList.remove("dragging");
|
||||||
el.style.touchAction = "";
|
el.style.touchAction = "";
|
||||||
this.shadow.querySelectorAll(".column[data-status]").forEach(c => (c as HTMLElement).classList.remove("drag-over"));
|
this.shadow.querySelectorAll(".column[data-status]").forEach(c => {
|
||||||
|
(c as HTMLElement).classList.remove("drag-over", "drag-source");
|
||||||
|
});
|
||||||
this.shadow.querySelector('.drop-indicator')?.remove();
|
this.shadow.querySelector('.drop-indicator')?.remove();
|
||||||
if (this.dragOverStatus && this.dragOverIndex >= 0) {
|
if (this.dragOverStatus && this.dragOverIndex >= 0) {
|
||||||
const task = this.tasks.find(t => t.id === this.dragTaskId);
|
const task = this.tasks.find(t => t.id === this.dragTaskId);
|
||||||
const sortOrder = this.computeSortOrder(this.dragOverStatus, this.dragOverIndex);
|
|
||||||
if (task) {
|
if (task) {
|
||||||
if (task.status === this.dragOverStatus) {
|
// No-op detection: same column, same position → skip
|
||||||
this.updateTask(this.dragTaskId!, { sort_order: sortOrder });
|
const colTasks = this.tasks
|
||||||
} else {
|
.filter(t => t.status === this.dragOverStatus && t.id !== this.dragTaskId)
|
||||||
this.moveTask(this.dragTaskId!, this.dragOverStatus, sortOrder);
|
.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
|
||||||
|
const currentIdx = this.tasks
|
||||||
|
.filter(t => t.status === task.status)
|
||||||
|
.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0))
|
||||||
|
.findIndex(t => t.id === task.id);
|
||||||
|
const sameColumn = task.status === this.dragOverStatus;
|
||||||
|
const samePosition = sameColumn && (this.dragOverIndex === currentIdx || this.dragOverIndex === currentIdx + 1);
|
||||||
|
|
||||||
|
if (!samePosition) {
|
||||||
|
const { sortOrder, rebalanceUpdates } = this.computeSortOrder(this.dragOverStatus!, this.dragOverIndex);
|
||||||
|
this.persistDrop(this.dragTaskId!, this.dragOverStatus!, sortOrder, rebalanceUpdates);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.dragTaskId = null;
|
this.dragTaskId = null;
|
||||||
|
this.dragSourceStatus = null;
|
||||||
this.dragOverStatus = null;
|
this.dragOverStatus = null;
|
||||||
this.dragOverIndex = -1;
|
this.dragOverIndex = -1;
|
||||||
});
|
});
|
||||||
el.addEventListener("pointercancel", () => {
|
el.addEventListener("pointercancel", () => {
|
||||||
el.classList.remove("dragging");
|
el.classList.remove("dragging");
|
||||||
el.style.touchAction = "";
|
el.style.touchAction = "";
|
||||||
this.shadow.querySelectorAll(".column[data-status]").forEach(c => (c as HTMLElement).classList.remove("drag-over"));
|
this.shadow.querySelectorAll(".column[data-status]").forEach(c => {
|
||||||
|
(c as HTMLElement).classList.remove("drag-over", "drag-source");
|
||||||
|
});
|
||||||
this.shadow.querySelector('.drop-indicator')?.remove();
|
this.shadow.querySelector('.drop-indicator')?.remove();
|
||||||
this.dragTaskId = null;
|
this.dragTaskId = null;
|
||||||
|
this.dragSourceStatus = null;
|
||||||
this.dragOverStatus = null;
|
this.dragOverStatus = null;
|
||||||
this.dragOverIndex = -1;
|
this.dragOverIndex = -1;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -373,6 +373,39 @@ routes.post("/api/spaces/:slug/tasks", async (c) => {
|
||||||
}, 201);
|
}, 201);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// PATCH /api/tasks/bulk-sort-order — rebalance sort orders for multiple tasks
|
||||||
|
// Registered BEFORE /api/tasks/:id to avoid param capture
|
||||||
|
routes.patch("/api/tasks/bulk-sort-order", async (c) => {
|
||||||
|
const body = await c.req.json();
|
||||||
|
const { updates } = body;
|
||||||
|
if (!Array.isArray(updates) || updates.length === 0) {
|
||||||
|
return c.json({ error: "updates array required" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group updates by board doc
|
||||||
|
const allBoardIds = _syncServer!.getDocIds().filter((docId) => docId.includes(':tasks:boards:'));
|
||||||
|
let applied = 0;
|
||||||
|
|
||||||
|
for (const { id, sort_order } of updates) {
|
||||||
|
if (!id || sort_order === undefined) continue;
|
||||||
|
for (const docId of allBoardIds) {
|
||||||
|
const doc = _syncServer!.getDoc<BoardDoc>(docId);
|
||||||
|
if (doc && doc.tasks[id]) {
|
||||||
|
_syncServer!.changeDoc<BoardDoc>(docId, `Rebalance sort order ${id}`, (d) => {
|
||||||
|
if (d.tasks[id]) {
|
||||||
|
d.tasks[id].sortOrder = sort_order;
|
||||||
|
d.tasks[id].updatedAt = Date.now();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
applied++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ ok: true, applied });
|
||||||
|
});
|
||||||
|
|
||||||
// PATCH /api/tasks/:id — update task (status change, assignment, etc.)
|
// PATCH /api/tasks/:id — update task (status change, assignment, etc.)
|
||||||
routes.patch("/api/tasks/:id", async (c) => {
|
routes.patch("/api/tasks/:id", async (c) => {
|
||||||
// Optional auth — track who updated
|
// Optional auth — track who updated
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ import {
|
||||||
listCommunities,
|
listCommunities,
|
||||||
deleteCommunity,
|
deleteCommunity,
|
||||||
updateSpaceMeta,
|
updateSpaceMeta,
|
||||||
|
setMember,
|
||||||
} from "./community-store";
|
} from "./community-store";
|
||||||
import type { NestPermissions, SpaceRefFilter } from "./community-store";
|
import type { NestPermissions, SpaceRefFilter } from "./community-store";
|
||||||
import { ensureDemoCommunity } from "./seed-demo";
|
import { ensureDemoCommunity } from "./seed-demo";
|
||||||
|
|
@ -586,6 +587,23 @@ app.post("/api/internal/provision", async (c) => {
|
||||||
return c.json({ status: "created", slug: space }, 201);
|
return c.json({ status: "created", slug: space }, 201);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// POST /api/internal/sync-space-member — called by EncryptID after identity invite claim
|
||||||
|
// Syncs a member from EncryptID's space_members (PostgreSQL) to the Automerge doc
|
||||||
|
app.post("/api/internal/sync-space-member", async (c) => {
|
||||||
|
const body = await c.req.json<{ spaceSlug: string; userDid: string; role: string; username?: string }>();
|
||||||
|
if (!body.spaceSlug || !body.userDid || !body.role) {
|
||||||
|
return c.json({ error: "spaceSlug, userDid, and role are required" }, 400);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await loadCommunity(body.spaceSlug);
|
||||||
|
setMember(body.spaceSlug, body.userDid, body.role as any, body.username);
|
||||||
|
return c.json({ ok: true });
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("sync-space-member error:", err.message);
|
||||||
|
return c.json({ error: "Failed to sync member" }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// POST /api/internal/mint-crdt — called by onramp-service after fiat payment confirmed
|
// POST /api/internal/mint-crdt — called by onramp-service after fiat payment confirmed
|
||||||
app.post("/api/internal/mint-crdt", async (c) => {
|
app.post("/api/internal/mint-crdt", async (c) => {
|
||||||
const internalKey = c.req.header("X-Internal-Key");
|
const internalKey = c.req.header("X-Internal-Key");
|
||||||
|
|
@ -3081,7 +3099,19 @@ const server = Bun.serve<WSData>({
|
||||||
if (vis === "private" && claims && spaceData) {
|
if (vis === "private" && claims && spaceData) {
|
||||||
const callerDid = (claims.did as string) || `did:key:${(claims.sub as string).slice(0, 32)}`;
|
const callerDid = (claims.did as string) || `did:key:${(claims.sub as string).slice(0, 32)}`;
|
||||||
const isOwner = spaceData.meta?.ownerDID === claims.sub || spaceData.meta?.ownerDID === callerDid;
|
const isOwner = spaceData.meta?.ownerDID === claims.sub || spaceData.meta?.ownerDID === callerDid;
|
||||||
const isMember = spaceData.members?.[claims.sub] || spaceData.members?.[callerDid];
|
let isMember = spaceData.members?.[claims.sub] || spaceData.members?.[callerDid];
|
||||||
|
// Fallback: check EncryptID space_members (handles identity-invite claims not yet synced to Automerge)
|
||||||
|
if (!isOwner && !isMember) {
|
||||||
|
try {
|
||||||
|
const memberRes = await fetch(`${ENCRYPTID_INTERNAL}/api/spaces/${encodeURIComponent(communitySlug)}/members/${encodeURIComponent(callerDid)}`);
|
||||||
|
if (memberRes.ok) {
|
||||||
|
const memberData = await memberRes.json() as { role: string; userDID: string };
|
||||||
|
// Sync to Automerge so future connections don't need the fallback
|
||||||
|
setMember(communitySlug, callerDid, memberData.role as any, (claims as any).username);
|
||||||
|
isMember = { role: memberData.role };
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
if (!isOwner && !isMember) {
|
if (!isOwner && !isMember) {
|
||||||
return new Response("You don't have access to this space", { status: 403 });
|
return new Response("You don't have access to this space", { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
@ -3091,12 +3121,15 @@ const server = Bun.serve<WSData>({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve the caller's space role
|
// Resolve the caller's space role (re-read doc in case setMember was called during fallback sync)
|
||||||
if (spaceData) {
|
const spaceDataFresh = getDocumentData(communitySlug);
|
||||||
if (claims && spaceData.meta.ownerDID === claims.sub) {
|
if (spaceDataFresh || spaceData) {
|
||||||
|
const sd = spaceDataFresh || spaceData;
|
||||||
|
const callerDid = claims ? ((claims.did as string) || `did:key:${(claims.sub as string).slice(0, 32)}`) : undefined;
|
||||||
|
if (claims && sd!.meta.ownerDID === claims.sub) {
|
||||||
spaceRole = 'admin';
|
spaceRole = 'admin';
|
||||||
} else if (claims && spaceData.members?.[claims.sub]) {
|
} else if (claims && (sd!.members?.[claims.sub]?.role || (callerDid && sd!.members?.[callerDid]?.role))) {
|
||||||
spaceRole = spaceData.members[claims.sub].role;
|
spaceRole = sd!.members?.[claims.sub]?.role || sd!.members?.[callerDid!]?.role;
|
||||||
} else {
|
} else {
|
||||||
// Non-member defaults by visibility
|
// Non-member defaults by visibility
|
||||||
const vis = spaceConfig?.visibility;
|
const vis = spaceConfig?.visibility;
|
||||||
|
|
|
||||||
|
|
@ -5592,6 +5592,18 @@ app.post('/api/invites/identity/:token/claim', async (c) => {
|
||||||
const inviterUser = await getUserById(invite.invitedByUserId);
|
const inviterUser = await getUserById(invite.invitedByUserId);
|
||||||
const inviterDid = inviterUser?.did || `did:key:${invite.invitedByUserId.slice(0, 32)}`;
|
const inviterDid = inviterUser?.did || `did:key:${invite.invitedByUserId.slice(0, 32)}`;
|
||||||
await upsertSpaceMember(invite.spaceSlug, did, invite.spaceRole, inviterDid);
|
await upsertSpaceMember(invite.spaceSlug, did, invite.spaceRole, inviterDid);
|
||||||
|
// Sync to rSpace's Automerge doc so the member has immediate access
|
||||||
|
const rspaceUrl = process.env.RSPACE_INTERNAL_URL || 'http://rspace-online:3000';
|
||||||
|
fetch(`${rspaceUrl}/api/internal/sync-space-member`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
spaceSlug: invite.spaceSlug,
|
||||||
|
userDid: did,
|
||||||
|
role: invite.spaceRole,
|
||||||
|
username: inviteeUser?.username,
|
||||||
|
}),
|
||||||
|
}).catch((err) => console.error('EncryptID: Failed to sync space member to Automerge:', err.message));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-add email to OIDC client allowlist if this is a client invite
|
// Auto-add email to OIDC client allowlist if this is a client invite
|
||||||
|
|
@ -5909,9 +5921,12 @@ function joinPage(token: string): string {
|
||||||
// Success!
|
// Success!
|
||||||
document.getElementById('registerForm').style.display = 'none';
|
document.getElementById('registerForm').style.display = 'none';
|
||||||
statusEl.style.display = 'none';
|
statusEl.style.display = 'none';
|
||||||
|
const destUrl = claimData.spaceSlug
|
||||||
|
? 'https://' + claimData.spaceSlug + '.rspace.online'
|
||||||
|
: 'https://rspace.online';
|
||||||
successEl.innerHTML = '<strong>Welcome to rSpace!</strong><br>Your identity has been created and your passkey is set up.' +
|
successEl.innerHTML = '<strong>Welcome to rSpace!</strong><br>Your identity has been created and your passkey is set up.' +
|
||||||
(claimData.spaceSlug ? '<br>You\\'ve been added to <strong>' + claimData.spaceSlug + '</strong>.' : '') +
|
(claimData.spaceSlug ? '<br>You\\'ve been added to <strong>' + claimData.spaceSlug + '</strong>.' : '') +
|
||||||
'<br><br><a href="https://rspace.online" style="color: #7c3aed;">Go to rSpace →</a>';
|
'<br><br><a href="' + destUrl + '" style="color: #7c3aed;">Go to ' + (claimData.spaceSlug || 'rSpace') + ' →</a>';
|
||||||
successEl.style.display = 'block';
|
successEl.style.display = 'block';
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue