feat(rtasks): ClickUp two-way sync integration

Add bidirectional sync between rTasks and ClickUp:
- API client with 100 req/min rate limiter
- OAuth2 + personal API token connection flows
- Import wizard (workspace → space → list picker)
- Outbound push queue (5s intervals, 10-item batches)
- Inbound webhook with HMAC-SHA256 validation
- Field-level conflict detection (rTasks wins)
- Source badges (purple CU) with sync status dots on task cards
- Sync status indicator in board header for connected boards

Also fix 6 pre-existing TS errors across crowdsurf, rcal, rnotes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-24 17:37:01 -07:00
parent eea3443cba
commit 41051715b9
13 changed files with 1724 additions and 26 deletions

View File

@ -92,7 +92,7 @@ class FolkCrowdSurfDashboard extends HTMLElement {
} else { } else {
this.initMultiplayer(); this.initMultiplayer();
} }
this._stopPresence = startPresenceHeartbeat(() => ({ module: 'crowdsurf', context: this.prompts[this.currentPromptIndex]?.title || 'CrowdSurf' })); this._stopPresence = startPresenceHeartbeat(() => ({ module: 'crowdsurf', context: this.prompts[this.currentPromptIndex]?.text || 'CrowdSurf' }));
} }
disconnectedCallback() { disconnectedCallback() {

View File

@ -1058,9 +1058,10 @@ function migrateTagsField(space: string) {
}; };
_syncServer.changeDoc<CalendarDoc>(docId, 'migrate: add tags field', (d) => { _syncServer.changeDoc<CalendarDoc>(docId, 'migrate: add tags field', (d) => {
for (const ev of Object.values(d.events)) { for (const key of Object.keys(d.events)) {
const ev = d.events[key] as any;
if (!('tags' in ev)) { if (!('tags' in ev)) {
(ev as any).tags = demoTags[ev.title] || null; ev.tags = demoTags[ev.title] || null;
} }
} }
}); });

View File

@ -1478,8 +1478,8 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
this._stopPresence = startPresenceHeartbeat(() => ({ this._stopPresence = startPresenceHeartbeat(() => ({
module: 'rnotes', module: 'rnotes',
context: this.selectedNote context: this.selectedNote
? `${this.selectedNotebook?.name || 'Notebook'} > ${this.selectedNote.title}` ? `${this.selectedNotebook?.title || 'Notebook'} > ${this.selectedNote.title}`
: this.selectedNotebook?.name || '', : this.selectedNotebook?.title || '',
notebookId: this.selectedNotebook?.id, notebookId: this.selectedNotebook?.id,
noteId: this.selectedNote?.id, noteId: this.selectedNote?.id,
})); }));
@ -1503,8 +1503,8 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
sharedBroadcastPresence({ sharedBroadcastPresence({
module: 'rnotes', module: 'rnotes',
context: this.selectedNote context: this.selectedNote
? `${this.selectedNotebook?.name || 'Notebook'} > ${this.selectedNote.title}` ? `${this.selectedNotebook?.title || 'Notebook'} > ${this.selectedNote.title}`
: this.selectedNotebook?.name || '', : this.selectedNotebook?.title || '',
notebookId: this.selectedNotebook?.id, notebookId: this.selectedNotebook?.id,
noteId: this.selectedNote?.id, noteId: this.selectedNote?.id,
}); });

View File

@ -34,6 +34,20 @@ class FolkTasksBoard extends HTMLElement {
private _history = new ViewHistory<"list" | "board">("list"); private _history = new ViewHistory<"list" | "board">("list");
private _backlogTaskId: string | null = null; private _backlogTaskId: string | null = null;
private _stopPresence: (() => void) | null = null; private _stopPresence: (() => void) | null = null;
// ClickUp integration state
private _cuConnected = false;
private _cuTeamName = '';
private _cuPendingTasks = 0;
private _cuShowPanel = false;
private _cuStep: 'token' | 'workspace' | 'list' | 'config' | 'importing' | 'done' = 'token';
private _cuWorkspaces: any[] = [];
private _cuSpaces: any[] = [];
private _cuLists: any[] = [];
private _cuSelectedTeam = '';
private _cuSelectedSpace = '';
private _cuSelectedList = '';
private _cuEnableSync = true;
private _cuImportResult: { boardId: string; taskCount: number } | null = null;
private _tour!: TourEngine; private _tour!: TourEngine;
private static readonly TOUR_STEPS = [ private static readonly TOUR_STEPS = [
{ target: '.workspace-card', title: "Workspaces", message: "Select a workspace to open its kanban board.", advanceOnClick: true }, { target: '.workspace-card', title: "Workspaces", message: "Select a workspace to open its kanban board.", advanceOnClick: true },
@ -62,6 +76,7 @@ class FolkTasksBoard extends HTMLElement {
else { else {
this.subscribeOffline(); this.subscribeOffline();
this.loadWorkspaces(); this.loadWorkspaces();
this.loadClickUpStatus();
this.render(); this.render();
} }
if (!localStorage.getItem("rtasks_tour_done")) { if (!localStorage.getItem("rtasks_tour_done")) {
@ -162,11 +177,219 @@ class FolkTasksBoard extends HTMLElement {
if (spaceRes.ok) { if (spaceRes.ok) {
const space = await spaceRes.json(); const space = await spaceRes.json();
if (space.statuses?.length) this.statuses = space.statuses; if (space.statuses?.length) this.statuses = space.statuses;
this._boardClickup = space.clickup || null;
} }
} catch { this.tasks = []; } } catch { this.tasks = []; }
this.render(); this.render();
} }
private async loadClickUpStatus() {
if (this.isDemo) return;
try {
const base = this.getApiBase();
const res = await fetch(`${base}/api/clickup/status`);
if (res.ok) {
const data = await res.json();
this._cuConnected = data.connected;
this._cuTeamName = data.teamName || '';
this._cuPendingTasks = data.pendingTasks || 0;
}
} catch {}
}
private async connectClickUpToken(token: string) {
try {
const base = this.getApiBase();
const res = await fetch(`${base}/api/clickup/connect-token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
if (res.ok) {
const data = await res.json();
this._cuConnected = true;
this._cuTeamName = data.teamName;
this._cuStep = 'workspace';
await this.loadClickUpWorkspaces();
} else {
this.error = 'Invalid ClickUp API token';
}
} catch {
this.error = 'Failed to connect to ClickUp';
}
this.render();
}
private async loadClickUpWorkspaces() {
try {
const base = this.getApiBase();
const res = await fetch(`${base}/api/clickup/workspaces`);
if (res.ok) this._cuWorkspaces = await res.json();
} catch { this._cuWorkspaces = []; }
}
private async loadClickUpSpaces(teamId: string) {
this._cuSelectedTeam = teamId;
try {
const base = this.getApiBase();
const res = await fetch(`${base}/api/clickup/spaces/${teamId}`);
if (res.ok) this._cuSpaces = await res.json();
} catch { this._cuSpaces = []; }
this.render();
}
private async loadClickUpLists(spaceId: string) {
this._cuSelectedSpace = spaceId;
try {
const base = this.getApiBase();
const res = await fetch(`${base}/api/clickup/lists/${spaceId}`);
if (res.ok) this._cuLists = await res.json();
} catch { this._cuLists = []; }
this._cuStep = 'list';
this.render();
}
private async importClickUpList() {
if (!this._cuSelectedList) return;
this._cuStep = 'importing';
this.render();
try {
const base = this.getApiBase();
const res = await fetch(`${base}/api/clickup/import`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
listId: this._cuSelectedList,
enableSync: this._cuEnableSync,
}),
});
if (res.ok) {
this._cuImportResult = await res.json();
this._cuStep = 'done';
} else {
this.error = 'Import failed';
this._cuStep = 'list';
}
} catch {
this.error = 'Import failed';
this._cuStep = 'list';
}
this.loadWorkspaces();
this.render();
}
private async disconnectClickUp() {
try {
const base = this.getApiBase();
await fetch(`${base}/api/clickup/disconnect`, { method: 'POST' });
this._cuConnected = false;
this._cuTeamName = '';
this._cuShowPanel = false;
} catch {}
this.render();
}
private renderClickUpPanel(): string {
if (!this._cuShowPanel) return '';
if (this._cuStep === 'token' && !this._cuConnected) {
return `
<div class="cu-panel">
<h3>Connect ClickUp</h3>
<p style="font-size:12px;color:var(--rs-text-muted);margin:0 0 10px">Enter your ClickUp Personal API Token (Settings &gt; Apps)</p>
<input type="password" id="cu-token-input" placeholder="pk_..." autocomplete="off">
<div class="cu-panel-actions">
<button class="cu-btn-primary" id="cu-connect-btn">Connect</button>
<button class="cu-btn-secondary" id="cu-cancel-btn">Cancel</button>
</div>
</div>`;
}
if (this._cuStep === 'workspace') {
return `
<div class="cu-panel">
<h3>Select Workspace</h3>
${this._cuWorkspaces.map(w => `
<div class="cu-list-item" data-cu-team="${w.id}">
<span class="cu-list-name">${this.esc(w.name)}</span>
<span class="cu-list-meta">${w.members || 0} members</span>
</div>
`).join('')}
${this._cuSpaces.length > 0 ? `
<h3 style="margin-top:12px">Select Space</h3>
${this._cuSpaces.map(s => `
<div class="cu-list-item" data-cu-space="${s.id}">
<span class="cu-list-name">${this.esc(s.name)}</span>
</div>
`).join('')}
` : ''}
<div class="cu-panel-actions">
<button class="cu-btn-secondary" id="cu-cancel-btn">Cancel</button>
</div>
</div>`;
}
if (this._cuStep === 'list') {
return `
<div class="cu-panel">
<h3>Select List to Import</h3>
${this._cuLists.map(l => `
<div class="cu-list-item ${this._cuSelectedList === l.id ? 'selected' : ''}" data-cu-list="${l.id}">
<div>
<span class="cu-list-name">${this.esc(l.name)}</span>
${l.folder ? `<span class="cu-list-meta"> in ${this.esc(l.folder)}</span>` : ''}
</div>
<span class="cu-list-meta">${l.taskCount} tasks</span>
</div>
`).join('')}
<div style="margin-top:10px">
<label style="font-size:12px;display:flex;align-items:center;gap:6px;color:var(--rs-text-secondary)">
<input type="checkbox" id="cu-sync-toggle" ${this._cuEnableSync ? 'checked' : ''}> Enable two-way sync
</label>
</div>
<div class="cu-panel-actions">
<button class="cu-btn-primary" id="cu-import-btn" ${!this._cuSelectedList ? 'disabled' : ''}>Import</button>
<button class="cu-btn-secondary" id="cu-back-btn">Back</button>
<button class="cu-btn-secondary" id="cu-cancel-btn">Cancel</button>
</div>
</div>`;
}
if (this._cuStep === 'importing') {
return `<div class="cu-panel"><div class="cu-progress">Importing tasks from ClickUp...</div></div>`;
}
if (this._cuStep === 'done' && this._cuImportResult) {
return `
<div class="cu-panel">
<h3>Import Complete</h3>
<p style="font-size:13px;color:var(--rs-text-secondary);margin:0 0 8px">
Imported <strong>${this._cuImportResult.taskCount}</strong> tasks into board <strong>${this.esc(this._cuImportResult.boardId)}</strong>
</p>
<div class="cu-panel-actions">
<button class="cu-btn-primary" id="cu-open-board" data-cu-board="${this._cuImportResult.boardId}">Open Board</button>
<button class="cu-btn-secondary" id="cu-cancel-btn">Close</button>
</div>
</div>`;
}
// Connected state — show disconnect option
if (this._cuConnected) {
return `
<div class="cu-panel">
<h3>ClickUp Connected</h3>
<p style="font-size:12px;color:var(--rs-text-muted);margin:0 0 8px">Workspace: ${this.esc(this._cuTeamName)}</p>
<div class="cu-panel-actions">
<button class="cu-btn-primary" id="cu-import-start">Import List</button>
<button class="cu-btn-secondary" id="cu-disconnect-btn" style="color:#f87171;border-color:#f87171 !important">Disconnect</button>
<button class="cu-btn-secondary" id="cu-cancel-btn">Close</button>
</div>
</div>`;
}
return '';
}
private async createWorkspace() { private async createWorkspace() {
const name = prompt("Workspace name:"); const name = prompt("Workspace name:");
if (!name?.trim()) return; if (!name?.trim()) return;
@ -390,6 +613,36 @@ class FolkTasksBoard extends HTMLElement {
.checklist-title { font-size: 13px; font-weight: 500; flex: 1; cursor: text; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .checklist-title { font-size: 13px; font-weight: 500; flex: 1; cursor: text; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.checklist-badges { display: flex; gap: 4px; flex-shrink: 0; } .checklist-badges { display: flex; gap: 4px; flex-shrink: 0; }
/* ClickUp integration */
.cu-badge { display: inline-flex; align-items: center; gap: 3px; font-size: 9px; padding: 1px 5px; border-radius: 3px; background: #7b68ee; color: #fff; text-decoration: none; font-weight: 600; vertical-align: middle; }
.cu-badge:hover { background: #6c5ce7; }
.cu-sync-dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; }
.cu-sync-dot.synced { background: #22c55e; }
.cu-sync-dot.pending-push { background: #facc15; }
.cu-sync-dot.conflict { background: #f87171; }
.cu-sync-dot.push-failed { background: #ef4444; }
.cu-board-status { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--rs-text-muted); padding: 2px 8px; border-radius: 4px; background: var(--rs-bg-surface-sunken); }
.cu-panel { background: var(--rs-bg-surface); border: 1px solid var(--rs-border-strong); border-radius: 10px; padding: 16px; margin-bottom: 16px; }
.cu-panel h3 { font-size: 14px; font-weight: 600; margin: 0 0 12px; }
.cu-panel input[type="text"], .cu-panel input[type="password"] {
width: 100%; padding: 8px 10px; border-radius: 6px; border: 1px solid var(--rs-border-strong);
background: var(--rs-bg-surface-sunken); color: var(--rs-text-primary); font-size: 13px; margin-bottom: 8px; outline: none; font-family: inherit;
}
.cu-panel input:focus { border-color: var(--rs-primary-hover); }
.cu-panel-actions { display: flex; gap: 8px; margin-top: 8px; }
.cu-panel-actions button { padding: 6px 14px; border-radius: 6px; border: none; font-size: 12px; cursor: pointer; font-weight: 600; }
.cu-btn-primary { background: #7b68ee; color: #fff; }
.cu-btn-primary:hover { background: #6c5ce7; }
.cu-btn-secondary { background: transparent; color: var(--rs-text-muted); border: 1px solid var(--rs-border-strong) !important; }
.cu-list-item { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; border-radius: 6px; cursor: pointer; transition: background 0.1s; }
.cu-list-item:hover { background: var(--rs-bg-surface-sunken); }
.cu-list-item.selected { background: rgba(123, 104, 238, 0.15); border: 1px solid #7b68ee; }
.cu-list-name { font-size: 13px; font-weight: 500; }
.cu-list-meta { font-size: 11px; color: var(--rs-text-muted); }
.cu-progress { font-size: 13px; color: var(--rs-text-muted); padding: 20px; text-align: center; }
.cu-connect-btn { padding: 4px 10px; border-radius: 6px; border: 1px solid #7b68ee; background: transparent; color: #7b68ee; cursor: pointer; font-size: 12px; font-weight: 600; }
.cu-connect-btn:hover { background: rgba(123, 104, 238, 0.1); }
@media (max-width: 768px) { @media (max-width: 768px) {
.board { flex-direction: column; overflow-x: visible; } .board { flex-direction: column; overflow-x: visible; }
.column { min-width: 100%; max-width: 100%; } .column { min-width: 100%; max-width: 100%; }
@ -425,9 +678,11 @@ class FolkTasksBoard extends HTMLElement {
return ` return `
<div class="rapp-nav"> <div class="rapp-nav">
<span class="rapp-nav__title">Workspaces</span> <span class="rapp-nav__title">Workspaces</span>
${!this.isDemo ? `<button class="cu-connect-btn" id="cu-toggle-panel">${this._cuConnected ? 'CU Connected' : 'Connect ClickUp'}</button>` : ''}
<button class="rapp-nav__btn" id="create-ws">+ New Workspace</button> <button class="rapp-nav__btn" id="create-ws">+ New Workspace</button>
<button class="rapp-nav__btn" id="btn-tour" style="font-size:0.78rem;padding:4px 10px;opacity:0.7">Tour</button> <button class="rapp-nav__btn" id="btn-tour" style="font-size:0.78rem;padding:4px 10px;opacity:0.7">Tour</button>
</div> </div>
${this.renderClickUpPanel()}
${this.workspaces.length > 0 ? `<div class="workspace-grid"> ${this.workspaces.length > 0 ? `<div class="workspace-grid">
${this.workspaces.map(ws => ` ${this.workspaces.map(ws => `
<div class="workspace-card" data-ws="${ws.slug}"> <div class="workspace-card" data-ws="${ws.slug}">
@ -461,11 +716,17 @@ class FolkTasksBoard extends HTMLElement {
</div>`; </div>`;
} }
private _boardClickup: { listName?: string; syncEnabled?: boolean } | null = null;
private renderBoard(): string { private renderBoard(): string {
const cuSyncInfo = this._boardClickup?.syncEnabled
? `<span class="cu-board-status"><span class="cu-sync-dot synced"></span> CU ${this.esc(this._boardClickup.listName || '')}</span>`
: '';
return ` return `
<div class="rapp-nav"> <div class="rapp-nav">
${this._history.canGoBack ? '<button class="rapp-nav__back" data-back="list">\u2190 Workspaces</button>' : ''} ${this._history.canGoBack ? '<button class="rapp-nav__back" data-back="list">\u2190 Workspaces</button>' : ''}
<span class="rapp-nav__title">${this.esc(this.workspaceSlug)}</span> <span class="rapp-nav__title">${this.esc(this.workspaceSlug)}</span>
${cuSyncInfo}
<div class="view-toggle"> <div class="view-toggle">
<button class="view-toggle__btn ${this.boardView === 'board' ? 'active' : ''}" data-set-view="board">Board</button> <button class="view-toggle__btn ${this.boardView === 'board' ? 'active' : ''}" data-set-view="board">Board</button>
<button class="view-toggle__btn ${this.boardView === 'checklist' ? 'active' : ''}" data-set-view="checklist">List</button> <button class="view-toggle__btn ${this.boardView === 'checklist' ? 'active' : ''}" data-set-view="checklist">List</button>
@ -537,11 +798,14 @@ class FolkTasksBoard extends HTMLElement {
const map: Record<string, string> = { URGENT: "badge-urgent", HIGH: "badge-high", MEDIUM: "badge-medium", LOW: "badge-low" }; const map: Record<string, string> = { URGENT: "badge-urgent", HIGH: "badge-high", MEDIUM: "badge-medium", LOW: "badge-low" };
return map[p] ? `<span class="badge clickable ${map[p]}" data-cycle-priority="${task.id}">${this.esc(p.toLowerCase())}</span>` : ""; return map[p] ? `<span class="badge clickable ${map[p]}" data-cycle-priority="${task.id}">${this.esc(p.toLowerCase())}</span>` : "";
}; };
const cuBadge = task.clickup
? `<a class="cu-badge" href="${this.esc(task.clickup.url)}" target="_blank" rel="noopener" title="Open in ClickUp">CU <span class="cu-sync-dot ${task.clickup.syncStatus || 'synced'}"></span></a>`
: '';
return ` return `
<div class="task-card" draggable="${isEditing ? "false" : "true"}" data-task-id="${task.id}" data-collab-id="task:${task.id}"> <div class="task-card" draggable="${isEditing ? "false" : "true"}" data-task-id="${task.id}" data-collab-id="task:${task.id}">
${isEditing ${isEditing
? `<input class="task-title-input" data-edit-title="${task.id}" value="${this.esc(task.title)}">` ? `<input class="task-title-input" data-edit-title="${task.id}" value="${this.esc(task.title)}">`
: `<div class="task-title" data-start-edit="${task.id}" style="cursor:text">${this.esc(task.title)}</div>`} : `<div class="task-title" data-start-edit="${task.id}" style="cursor:text">${this.esc(task.title)} ${cuBadge}</div>`}
<div class="task-meta"> <div class="task-meta">
${priorityBadge(task.priority || "")} ${priorityBadge(task.priority || "")}
${(task.labels || []).map((l: string) => `<span class="badge">${this.esc(l)}</span>`).join("")} ${(task.labels || []).map((l: string) => `<span class="badge">${this.esc(l)}</span>`).join("")}
@ -660,6 +924,62 @@ class FolkTasksBoard extends HTMLElement {
}); });
}); });
// ClickUp panel listeners
this.shadow.getElementById("cu-toggle-panel")?.addEventListener("click", () => {
this._cuShowPanel = !this._cuShowPanel;
if (this._cuShowPanel && !this._cuConnected) this._cuStep = 'token';
this.render();
});
this.shadow.getElementById("cu-connect-btn")?.addEventListener("click", () => {
const input = this.shadow.getElementById("cu-token-input") as HTMLInputElement;
if (input?.value?.trim()) this.connectClickUpToken(input.value.trim());
});
this.shadow.getElementById("cu-token-input")?.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Enter") {
const input = e.target as HTMLInputElement;
if (input.value?.trim()) this.connectClickUpToken(input.value.trim());
}
});
this.shadow.getElementById("cu-cancel-btn")?.addEventListener("click", () => {
this._cuShowPanel = false;
this._cuStep = 'token';
this.render();
});
this.shadow.getElementById("cu-disconnect-btn")?.addEventListener("click", () => this.disconnectClickUp());
this.shadow.getElementById("cu-import-start")?.addEventListener("click", () => {
this._cuStep = 'workspace';
this.loadClickUpWorkspaces().then(() => this.render());
});
this.shadow.getElementById("cu-import-btn")?.addEventListener("click", () => this.importClickUpList());
this.shadow.getElementById("cu-back-btn")?.addEventListener("click", () => {
this._cuStep = 'workspace';
this.render();
});
this.shadow.querySelectorAll("[data-cu-team]").forEach(el => {
el.addEventListener("click", () => this.loadClickUpSpaces((el as HTMLElement).dataset.cuTeam!));
});
this.shadow.querySelectorAll("[data-cu-space]").forEach(el => {
el.addEventListener("click", () => this.loadClickUpLists((el as HTMLElement).dataset.cuSpace!));
});
this.shadow.querySelectorAll("[data-cu-list]").forEach(el => {
el.addEventListener("click", () => {
this._cuSelectedList = (el as HTMLElement).dataset.cuList!;
this.render();
});
});
this.shadow.getElementById("cu-sync-toggle")?.addEventListener("change", (e) => {
this._cuEnableSync = (e.target as HTMLInputElement).checked;
});
this.shadow.getElementById("cu-open-board")?.addEventListener("click", () => {
const board = (this.shadow.getElementById("cu-open-board") as HTMLElement)?.dataset.cuBoard;
if (board) {
this._cuShowPanel = false;
this._cuStep = 'token';
this._cuImportResult = null;
this.openBoard(board);
}
});
// Pointer events drag-and-drop on task cards (works with touch, pen, mouse) // Pointer events drag-and-drop on task cards (works with touch, pen, mouse)
this.shadow.querySelectorAll(".task-card[draggable]").forEach(card => { this.shadow.querySelectorAll(".task-card[draggable]").forEach(card => {
const el = card as HTMLElement; const el = card as HTMLElement;

View File

@ -0,0 +1,165 @@
/**
* ClickUp API v2 client with rate limiting.
*
* Rate limit: 100 requests per minute per token.
* Uses a sliding-window token bucket with automatic backoff.
*/
const CLICKUP_API = 'https://api.clickup.com/api/v2';
const RATE_LIMIT = 100;
const RATE_WINDOW_MS = 60_000;
// ── Rate limiter ──
interface RateBucket {
timestamps: number[];
}
const buckets = new Map<string, RateBucket>();
async function waitForSlot(token: string): Promise<void> {
let bucket = buckets.get(token);
if (!bucket) {
bucket = { timestamps: [] };
buckets.set(token, bucket);
}
const now = Date.now();
bucket.timestamps = bucket.timestamps.filter(t => now - t < RATE_WINDOW_MS);
if (bucket.timestamps.length >= RATE_LIMIT) {
const oldest = bucket.timestamps[0];
const waitMs = RATE_WINDOW_MS - (now - oldest) + 50;
await new Promise(r => setTimeout(r, waitMs));
return waitForSlot(token); // retry
}
bucket.timestamps.push(Date.now());
}
// ── HTTP helpers ──
export class ClickUpApiError extends Error {
constructor(
public statusCode: number,
public body: string,
public endpoint: string,
) {
super(`ClickUp API ${statusCode} on ${endpoint}: ${body.slice(0, 200)}`);
this.name = 'ClickUpApiError';
}
}
async function request(
token: string,
method: string,
path: string,
body?: Record<string, any>,
): Promise<any> {
await waitForSlot(token);
const url = `${CLICKUP_API}${path}`;
const headers: Record<string, string> = {
'Authorization': token,
'Content-Type': 'application/json',
};
const res = await fetch(url, {
method,
headers,
...(body ? { body: JSON.stringify(body) } : {}),
});
if (!res.ok) {
const text = await res.text();
throw new ClickUpApiError(res.status, text, `${method} ${path}`);
}
const contentType = res.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
return res.json();
}
return res.text();
}
// ── Client class ──
export class ClickUpClient {
constructor(private token: string) {}
// ── Teams (Workspaces) ──
async getTeams(): Promise<any[]> {
const data = await request(this.token, 'GET', '/team');
return data.teams || [];
}
// ── Spaces ──
async getSpaces(teamId: string): Promise<any[]> {
const data = await request(this.token, 'GET', `/team/${teamId}/space?archived=false`);
return data.spaces || [];
}
// ── Folders & Lists ──
async getFolders(spaceId: string): Promise<any[]> {
const data = await request(this.token, 'GET', `/space/${spaceId}/folder?archived=false`);
return data.folders || [];
}
async getFolderlessLists(spaceId: string): Promise<any[]> {
const data = await request(this.token, 'GET', `/space/${spaceId}/list?archived=false`);
return data.lists || [];
}
async getListsInFolder(folderId: string): Promise<any[]> {
const data = await request(this.token, 'GET', `/folder/${folderId}/list?archived=false`);
return data.lists || [];
}
// ── Tasks ──
async getTasks(listId: string, page = 0): Promise<any[]> {
const data = await request(
this.token, 'GET',
`/list/${listId}/task?page=${page}&include_closed=true&subtasks=true`,
);
return data.tasks || [];
}
async getTask(taskId: string): Promise<any> {
return request(this.token, 'GET', `/task/${taskId}`);
}
async createTask(listId: string, body: Record<string, any>): Promise<any> {
return request(this.token, 'POST', `/list/${listId}/task`, body);
}
async updateTask(taskId: string, body: Record<string, any>): Promise<any> {
return request(this.token, 'PUT', `/task/${taskId}`, body);
}
// ── Webhooks ──
async createWebhook(teamId: string, endpoint: string, events: string[], secret?: string): Promise<any> {
const body: Record<string, any> = { endpoint, events };
if (secret) body.secret = secret;
return request(this.token, 'POST', `/team/${teamId}/webhook`, body);
}
async deleteWebhook(webhookId: string): Promise<void> {
await request(this.token, 'DELETE', `/webhook/${webhookId}`);
}
async getWebhooks(teamId: string): Promise<any[]> {
const data = await request(this.token, 'GET', `/team/${teamId}/webhook`);
return data.webhooks || [];
}
// ── List details (for status mapping) ──
async getList(listId: string): Promise<any> {
return request(this.token, 'GET', `/list/${listId}`);
}
}

View File

@ -0,0 +1,189 @@
/**
* Bidirectional mapping between rTasks and ClickUp fields.
*
* Pure functions no side effects, no API calls.
*/
// ── Status mapping ──
const DEFAULT_STATUS_TO_CLICKUP: Record<string, string> = {
TODO: 'to do',
IN_PROGRESS: 'in progress',
REVIEW: 'in review',
DONE: 'complete',
};
const DEFAULT_CLICKUP_TO_STATUS: Record<string, string> = {
'to do': 'TODO',
'open': 'TODO',
'pending': 'TODO',
'in progress': 'IN_PROGRESS',
'in review': 'REVIEW',
'review': 'REVIEW',
'complete': 'DONE',
'closed': 'DONE',
'done': 'DONE',
};
export function toClickUpStatus(
rTasksStatus: string,
customMap?: Record<string, string>,
): string {
if (customMap?.[rTasksStatus]) return customMap[rTasksStatus];
return DEFAULT_STATUS_TO_CLICKUP[rTasksStatus] || rTasksStatus.toLowerCase().replace(/_/g, ' ');
}
export function fromClickUpStatus(
clickUpStatus: string,
reverseMap?: Record<string, string>,
): string {
const lower = clickUpStatus.toLowerCase();
if (reverseMap?.[lower]) return reverseMap[lower];
if (DEFAULT_CLICKUP_TO_STATUS[lower]) return DEFAULT_CLICKUP_TO_STATUS[lower];
// Fuzzy: check if any known keyword is contained
for (const [pattern, mapped] of Object.entries(DEFAULT_CLICKUP_TO_STATUS)) {
if (lower.includes(pattern)) return mapped;
}
return 'TODO'; // fallback
}
// ── Priority mapping ──
const PRIORITY_TO_CLICKUP: Record<string, number> = {
URGENT: 1,
HIGH: 2,
MEDIUM: 3,
LOW: 4,
};
const CLICKUP_TO_PRIORITY: Record<number, string> = {
1: 'URGENT',
2: 'HIGH',
3: 'MEDIUM',
4: 'LOW',
};
export function toClickUpPriority(priority: string | null): number | null {
if (!priority) return null;
return PRIORITY_TO_CLICKUP[priority] ?? 3;
}
export function fromClickUpPriority(cuPriority: number | null | undefined): string | null {
if (cuPriority == null) return null;
return CLICKUP_TO_PRIORITY[cuPriority] ?? 'MEDIUM';
}
// ── Content hashing for change detection ──
export async function contentHash(parts: {
title: string;
description: string;
status: string;
priority: string | null;
}): Promise<string> {
const raw = `${parts.title}\x00${parts.description}\x00${parts.status}\x00${parts.priority ?? ''}`;
const buf = new TextEncoder().encode(raw);
const hash = await crypto.subtle.digest('SHA-256', buf);
return Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, '0')).join('');
}
// ── Conflict detection ──
export interface FieldDiff {
field: string;
local: string;
remote: string;
}
export function detectConflicts(
localFields: { title: string; description: string; status: string; priority: string | null },
remoteFields: { title: string; description: string; status: string; priority: string | null },
baseFields: { title: string; description: string; status: string; priority: string | null },
): { conflicts: FieldDiff[]; localOnly: string[]; remoteOnly: string[] } {
const conflicts: FieldDiff[] = [];
const localOnly: string[] = [];
const remoteOnly: string[] = [];
for (const field of ['title', 'description', 'status', 'priority'] as const) {
const base = baseFields[field] ?? '';
const local = localFields[field] ?? '';
const remote = remoteFields[field] ?? '';
const localChanged = local !== base;
const remoteChanged = remote !== base;
if (localChanged && remoteChanged && local !== remote) {
conflicts.push({ field, local, remote });
} else if (localChanged && !remoteChanged) {
localOnly.push(field);
} else if (!localChanged && remoteChanged) {
remoteOnly.push(field);
}
}
return { conflicts, localOnly, remoteOnly };
}
// ── ClickUp task → rTasks fields ──
export function mapClickUpTaskToRTasks(cuTask: any, reverseStatusMap?: Record<string, string>): {
title: string;
description: string;
status: string;
priority: string | null;
labels: string[];
clickUpMeta: {
taskId: string;
listId: string;
url: string;
};
} {
return {
title: cuTask.name || 'Untitled',
description: cuTask.description || cuTask.text_content || '',
status: fromClickUpStatus(cuTask.status?.status || 'to do', reverseStatusMap),
priority: fromClickUpPriority(cuTask.priority?.id ? Number(cuTask.priority.id) : null),
labels: (cuTask.tags || []).map((t: any) => t.name),
clickUpMeta: {
taskId: cuTask.id,
listId: cuTask.list?.id || '',
url: cuTask.url || `https://app.clickup.com/t/${cuTask.id}`,
},
};
}
// ── rTasks fields → ClickUp API body ──
export function mapRTasksToClickUpBody(
task: { title: string; description: string; status: string; priority: string | null; labels: string[] },
statusMap?: Record<string, string>,
): Record<string, any> {
const body: Record<string, any> = {
name: task.title,
description: task.description,
status: toClickUpStatus(task.status, statusMap),
};
const p = toClickUpPriority(task.priority);
if (p != null) body.priority = p;
if (task.labels?.length) body.tags = task.labels;
return body;
}
// ── Build reverse status map from ClickUp list statuses ──
export function buildStatusMaps(clickUpStatuses: Array<{ status: string; type: string }>): {
statusMap: Record<string, string>;
reverseStatusMap: Record<string, string>;
} {
const statusMap: Record<string, string> = {};
const reverseStatusMap: Record<string, string> = {};
for (const s of clickUpStatuses) {
const mapped = fromClickUpStatus(s.status);
reverseStatusMap[s.status.toLowerCase()] = mapped;
// First ClickUp status for each rTasks status wins
if (!statusMap[mapped]) statusMap[mapped] = s.status;
}
return { statusMap, reverseStatusMap };
}

View File

@ -0,0 +1,510 @@
/**
* ClickUp sync service import, outbound watcher, inbound webhook.
*
* Outbound: registerWatcher on ':tasks:boards:' docs, queue + batch push.
* Inbound: webhook handler validates HMAC, maps fields, applies via changeDoc.
*/
import type { SyncServer } from '../../../server/local-first/sync-server';
import { ClickUpClient, ClickUpApiError } from './clickup-client';
import {
mapClickUpTaskToRTasks,
mapRTasksToClickUpBody,
contentHash,
buildStatusMaps,
detectConflicts,
} from './clickup-mapping';
import {
boardDocId,
clickupConnectionDocId,
createTaskItem,
} from '../schemas';
import type {
BoardDoc,
TaskItem,
ClickUpConnectionDoc,
ClickUpBoardMeta,
} from '../schemas';
// ── Outbound push queue ──
interface PushItem {
docId: string;
taskId: string;
retries: number;
}
const pushQueue: PushItem[] = [];
let pushTimerId: ReturnType<typeof setInterval> | null = null;
// Track last-pushed hashes to avoid re-pushing unchanged tasks
const lastPushedHash = new Map<string, string>();
// ── Init: register watcher + start queue processor ──
export function initClickUpSync(syncServer: SyncServer) {
syncServer.registerWatcher(':tasks:boards:', (docId, doc) => {
const boardDoc = doc as BoardDoc;
if (!boardDoc?.board?.clickup?.syncEnabled) return;
const cu = boardDoc.board.clickup;
for (const [taskId, task] of Object.entries(boardDoc.tasks)) {
if (!task.clickup) continue;
if (task.clickup.syncStatus === 'synced') {
// Check if content actually changed since last push
const key = `${docId}:${taskId}`;
const currentFields = `${task.title}\x00${task.description}\x00${task.status}\x00${task.priority ?? ''}`;
if (lastPushedHash.get(key) === currentFields) continue;
// Content may have changed — enqueue for push
if (!pushQueue.some(p => p.docId === docId && p.taskId === taskId)) {
pushQueue.push({ docId, taskId, retries: 0 });
}
}
}
});
// Start queue processor (runs every 5s)
if (!pushTimerId) {
pushTimerId = setInterval(() => processPushQueue(syncServer), 5_000);
}
console.log('[ClickUp] Sync watcher registered');
}
async function processPushQueue(syncServer: SyncServer) {
if (pushQueue.length === 0) return;
const batch = pushQueue.splice(0, 10); // drain ≤10 items
for (const item of batch) {
try {
const doc = syncServer.getDoc<BoardDoc>(item.docId);
if (!doc) continue;
const task = doc.tasks[item.taskId];
if (!task?.clickup) continue;
const cu = doc.board.clickup;
if (!cu?.syncEnabled) continue;
// Get connection token
const space = doc.meta.spaceSlug;
const connDoc = syncServer.getDoc<ClickUpConnectionDoc>(clickupConnectionDocId(space));
if (!connDoc?.clickup?.accessToken) continue;
const client = new ClickUpClient(connDoc.clickup.accessToken);
// Compute current hash
const hash = await contentHash({
title: task.title,
description: task.description,
status: task.status,
priority: task.priority,
});
// Skip if hash matches last synced
if (hash === task.clickup.contentHash) {
const key = `${item.docId}:${item.taskId}`;
lastPushedHash.set(key, `${task.title}\x00${task.description}\x00${task.status}\x00${task.priority ?? ''}`);
continue;
}
// Push to ClickUp
const body = mapRTasksToClickUpBody(
{ title: task.title, description: task.description, status: task.status, priority: task.priority, labels: [...task.labels] },
cu.statusMap,
);
await client.updateTask(task.clickup.taskId, body);
// Update sync status
syncServer.changeDoc<BoardDoc>(item.docId, `Sync task ${item.taskId} to ClickUp`, (d) => {
const t = d.tasks[item.taskId];
if (!t?.clickup) return;
t.clickup.syncStatus = 'synced';
t.clickup.contentHash = hash;
t.clickup.lastSyncedAt = Date.now();
});
const key = `${item.docId}:${item.taskId}`;
lastPushedHash.set(key, `${task.title}\x00${task.description}\x00${task.status}\x00${task.priority ?? ''}`);
} catch (err) {
item.retries++;
if (item.retries < 3) {
pushQueue.push(item); // re-enqueue
} else {
// Mark as failed
try {
syncServer.changeDoc<BoardDoc>(item.docId, `Mark push failed ${item.taskId}`, (d) => {
const t = d.tasks[item.taskId];
if (t?.clickup) t.clickup.syncStatus = 'push-failed';
});
} catch {}
console.error(`[ClickUp] Push failed after 3 retries for task ${item.taskId}:`, err);
}
}
}
}
// ── Import a ClickUp list into an rTasks board ──
export async function importClickUpList(
syncServer: SyncServer,
space: string,
boardSlug: string,
listId: string,
accessToken: string,
opts: { enableSync?: boolean; createNew?: boolean } = {},
): Promise<{ boardId: string; taskCount: number }> {
const client = new ClickUpClient(accessToken);
// Fetch list details for status mapping
const listInfo = await client.getList(listId);
const listStatuses = listInfo.statuses || [];
const { statusMap, reverseStatusMap } = buildStatusMaps(listStatuses);
// Fetch all tasks (paginate)
let allTasks: any[] = [];
let page = 0;
while (true) {
const batch = await client.getTasks(listId, page);
allTasks = allTasks.concat(batch);
if (batch.length < 100) break;
page++;
}
const docId = boardDocId(space, boardSlug);
let doc = syncServer.getDoc<BoardDoc>(docId);
if (!doc || opts.createNew) {
// Create new board
const Automerge = await import('@automerge/automerge');
const now = Date.now();
doc = Automerge.change(Automerge.init<BoardDoc>(), 'import ClickUp list', (d: BoardDoc) => {
d.meta = { module: 'tasks', collection: 'boards', version: 1, spaceSlug: space, createdAt: now };
d.board = {
id: boardSlug,
name: listInfo.name || 'Imported Board',
slug: boardSlug,
description: `Imported from ClickUp list: ${listInfo.name || listId}`,
icon: null,
ownerDid: null,
statuses: ['TODO', 'IN_PROGRESS', 'REVIEW', 'DONE'],
labels: [],
createdAt: now,
updatedAt: now,
};
d.tasks = {};
// Set ClickUp board meta
if (opts.enableSync) {
d.board.clickup = {
listId,
listName: listInfo.name || '',
workspaceId: listInfo.folder?.space?.id || '',
syncEnabled: true,
statusMap,
reverseStatusMap,
};
}
});
syncServer.setDoc(docId, doc!);
} else if (opts.enableSync) {
// Add ClickUp meta to existing board
syncServer.changeDoc<BoardDoc>(docId, 'Enable ClickUp sync', (d) => {
d.board.clickup = {
listId,
listName: listInfo.name || '',
workspaceId: listInfo.folder?.space?.id || '',
syncEnabled: true,
statusMap,
reverseStatusMap,
};
});
}
// Import tasks
let taskCount = 0;
for (const cuTask of allTasks) {
const mapped = mapClickUpTaskToRTasks(cuTask, reverseStatusMap);
const taskId = crypto.randomUUID();
const hash = await contentHash({
title: mapped.title,
description: mapped.description,
status: mapped.status,
priority: mapped.priority,
});
syncServer.changeDoc<BoardDoc>(docId, `Import task ${cuTask.id}`, (d) => {
d.tasks[taskId] = createTaskItem(taskId, space, mapped.title, {
description: mapped.description,
status: mapped.status,
priority: mapped.priority,
labels: mapped.labels,
sortOrder: taskCount * 1000,
createdBy: 'clickup-import',
});
d.tasks[taskId].clickup = {
taskId: mapped.clickUpMeta.taskId,
listId: mapped.clickUpMeta.listId,
url: mapped.clickUpMeta.url,
lastSyncedAt: Date.now(),
syncStatus: 'synced',
contentHash: hash,
};
});
taskCount++;
}
console.log(`[ClickUp] Imported ${taskCount} tasks from list ${listId} into ${boardSlug}`);
return { boardId: boardSlug, taskCount };
}
// ── Export rTasks board → ClickUp list ──
export async function pushBoardToClickUp(
syncServer: SyncServer,
space: string,
boardSlug: string,
listId: string,
accessToken: string,
statusMap?: Record<string, string>,
): Promise<{ pushed: number }> {
const client = new ClickUpClient(accessToken);
const docId = boardDocId(space, boardSlug);
const doc = syncServer.getDoc<BoardDoc>(docId);
if (!doc) throw new Error('Board not found');
let pushed = 0;
for (const [taskId, task] of Object.entries(doc.tasks)) {
if (task.clickup?.taskId) continue; // already linked
const body = mapRTasksToClickUpBody(
{ title: task.title, description: task.description, status: task.status, priority: task.priority, labels: [...task.labels] },
statusMap,
);
const created = await client.createTask(listId, body);
const hash = await contentHash({
title: task.title,
description: task.description,
status: task.status,
priority: task.priority,
});
syncServer.changeDoc<BoardDoc>(docId, `Link task ${taskId} to ClickUp`, (d) => {
const t = d.tasks[taskId];
if (!t) return;
t.clickup = {
taskId: created.id,
listId,
url: created.url || `https://app.clickup.com/t/${created.id}`,
lastSyncedAt: Date.now(),
syncStatus: 'synced',
contentHash: hash,
};
});
pushed++;
}
console.log(`[ClickUp] Pushed ${pushed} tasks from ${boardSlug} to ClickUp list ${listId}`);
return { pushed };
}
// ── Inbound webhook handler ──
export async function handleClickUpWebhook(
syncServer: SyncServer,
space: string,
body: any,
signature: string | null,
): Promise<{ ok: boolean; message: string }> {
// Get connection for HMAC validation
const connDoc = syncServer.getDoc<ClickUpConnectionDoc>(clickupConnectionDocId(space));
if (!connDoc?.clickup) {
return { ok: false, message: 'No ClickUp connection for this space' };
}
// Validate HMAC-SHA256 signature
if (signature && connDoc.clickup.webhookSecret) {
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(connDoc.clickup.webhookSecret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
);
const sig = await crypto.subtle.sign('HMAC', key, encoder.encode(JSON.stringify(body)));
const expected = Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, '0')).join('');
if (signature !== expected) {
return { ok: false, message: 'Invalid webhook signature' };
}
}
const event = body.event;
const taskId = body.task_id;
if (!taskId || !['taskUpdated', 'taskStatusUpdated', 'taskCreated'].includes(event)) {
return { ok: true, message: 'Event ignored' };
}
// Find which board doc has this ClickUp task
const boardDocIds = syncServer.getDocIds().filter(id => id.startsWith(`${space}:tasks:boards:`));
let targetDocId: string | null = null;
let targetTaskId: string | null = null;
for (const docId of boardDocIds) {
const doc = syncServer.getDoc<BoardDoc>(docId);
if (!doc?.board?.clickup?.syncEnabled) continue;
for (const [tid, task] of Object.entries(doc.tasks)) {
if (task.clickup?.taskId === taskId) {
targetDocId = docId;
targetTaskId = tid;
break;
}
}
if (targetDocId) break;
}
if (!targetDocId || !targetTaskId) {
// New task from ClickUp — only import if a board is watching this list
if (event === 'taskCreated' && body.list_id) {
for (const docId of boardDocIds) {
const doc = syncServer.getDoc<BoardDoc>(docId);
if (doc?.board?.clickup?.syncEnabled && doc.board.clickup.listId === body.list_id) {
// Fetch full task and import
const client = new ClickUpClient(connDoc.clickup.accessToken);
try {
const cuTask = await client.getTask(taskId);
const mapped = mapClickUpTaskToRTasks(cuTask, doc.board.clickup.reverseStatusMap);
const newTaskId = crypto.randomUUID();
const hash = await contentHash({
title: mapped.title,
description: mapped.description,
status: mapped.status,
priority: mapped.priority,
});
syncServer.changeDoc<BoardDoc>(docId, `Webhook: import new task ${taskId}`, (d) => {
d.tasks[newTaskId] = createTaskItem(newTaskId, space, mapped.title, {
description: mapped.description,
status: mapped.status,
priority: mapped.priority,
labels: mapped.labels,
sortOrder: Object.keys(d.tasks).length * 1000,
createdBy: 'clickup-webhook',
});
d.tasks[newTaskId].clickup = {
taskId: mapped.clickUpMeta.taskId,
listId: mapped.clickUpMeta.listId,
url: mapped.clickUpMeta.url,
lastSyncedAt: Date.now(),
syncStatus: 'synced',
contentHash: hash,
};
});
return { ok: true, message: `Imported new task ${taskId}` };
} catch (err) {
console.error(`[ClickUp] Webhook import failed for ${taskId}:`, err);
return { ok: false, message: 'Failed to import task' };
}
}
}
}
return { ok: true, message: 'Task not tracked' };
}
// Fetch latest task from ClickUp
const client = new ClickUpClient(connDoc.clickup.accessToken);
try {
const doc = syncServer.getDoc<BoardDoc>(targetDocId)!;
const cuTask = await client.getTask(taskId);
const mapped = mapClickUpTaskToRTasks(cuTask, doc.board.clickup?.reverseStatusMap);
const remoteHash = await contentHash({
title: mapped.title,
description: mapped.description,
status: mapped.status,
priority: mapped.priority,
});
const localTask = doc.tasks[targetTaskId];
if (!localTask) return { ok: true, message: 'Task not found locally' };
// Skip if remote hasn't changed
if (localTask.clickup?.remoteHash === remoteHash) {
return { ok: true, message: 'No changes detected' };
}
// Check for conflicts
const localHash = await contentHash({
title: localTask.title,
description: localTask.description,
status: localTask.status,
priority: localTask.priority,
});
const localChanged = localTask.clickup?.contentHash !== localHash;
const remoteChanged = localTask.clickup?.remoteHash !== remoteHash;
if (localChanged && remoteChanged) {
// Both sides changed — detect field-level conflicts
// rTasks wins on conflicts, apply remote-only changes
const baseFields = {
title: localTask.title,
description: localTask.description,
status: localTask.status,
priority: localTask.priority,
};
const { remoteOnly, conflicts } = detectConflicts(
{ title: localTask.title, description: localTask.description, status: localTask.status, priority: localTask.priority },
{ title: mapped.title, description: mapped.description, status: mapped.status, priority: mapped.priority },
baseFields,
);
syncServer.changeDoc<BoardDoc>(targetDocId, `Webhook: merge task ${taskId}`, (d) => {
const t = d.tasks[targetTaskId!];
if (!t) return;
// Apply remote-only field changes
for (const field of remoteOnly) {
if (field === 'title') t.title = mapped.title;
if (field === 'description') t.description = mapped.description;
if (field === 'status') t.status = mapped.status;
if (field === 'priority') t.priority = mapped.priority;
}
if (t.clickup) {
t.clickup.remoteHash = remoteHash;
t.clickup.lastSyncedAt = Date.now();
t.clickup.syncStatus = conflicts.length > 0 ? 'conflict' : 'synced';
}
t.updatedAt = Date.now();
});
} else {
// Only remote changed — apply all remote fields
syncServer.changeDoc<BoardDoc>(targetDocId, `Webhook: update task ${taskId}`, (d) => {
const t = d.tasks[targetTaskId!];
if (!t) return;
t.title = mapped.title;
t.description = mapped.description;
t.status = mapped.status;
t.priority = mapped.priority;
if (mapped.labels.length > 0) t.labels = mapped.labels;
if (t.clickup) {
t.clickup.remoteHash = remoteHash;
t.clickup.contentHash = remoteHash;
t.clickup.lastSyncedAt = Date.now();
t.clickup.syncStatus = 'synced';
}
t.updatedAt = Date.now();
});
}
return { ok: true, message: `Updated task ${taskId}` };
} catch (err) {
console.error(`[ClickUp] Webhook update failed for ${taskId}:`, err);
return { ok: false, message: 'Failed to update task' };
}
}

View File

@ -16,8 +16,11 @@ import type { RSpaceModule, SpaceLifecycleContext } from "../../shared/module";
import { verifyToken, extractToken } from "../../server/auth"; import { verifyToken, extractToken } from "../../server/auth";
import { renderLanding } from "./landing"; import { renderLanding } from "./landing";
import type { SyncServer } from '../../server/local-first/sync-server'; import type { SyncServer } from '../../server/local-first/sync-server';
import { boardSchema, boardDocId, createTaskItem } from './schemas'; import { boardSchema, boardDocId, createTaskItem, clickupConnectionDocId, clickupConnectionSchema } from './schemas';
import type { BoardDoc, TaskItem, BoardMeta } from './schemas'; import type { BoardDoc, TaskItem, BoardMeta, ClickUpConnectionDoc } from './schemas';
import { ClickUpClient } from './lib/clickup-client';
import { importClickUpList, pushBoardToClickUp, handleClickUpWebhook, initClickUpSync } from './lib/clickup-sync';
import { buildStatusMaps } from './lib/clickup-mapping';
// Email checklist routes exported separately — see checklist-routes.ts // Email checklist routes exported separately — see checklist-routes.ts
@ -277,6 +280,7 @@ routes.get("/api/spaces/:slug", async (c) => {
labels: doc.board.labels, labels: doc.board.labels,
created_at: new Date(doc.board.createdAt).toISOString(), created_at: new Date(doc.board.createdAt).toISOString(),
updated_at: new Date(doc.board.updatedAt).toISOString(), updated_at: new Date(doc.board.updatedAt).toISOString(),
...(doc.board.clickup ? { clickup: { listId: doc.board.clickup.listId, listName: doc.board.clickup.listName, syncEnabled: doc.board.clickup.syncEnabled } } : {}),
}); });
}); });
@ -301,6 +305,7 @@ routes.get("/api/spaces/:slug/tasks", async (c) => {
sort_order: t.sortOrder, sort_order: t.sortOrder,
created_at: new Date(t.createdAt).toISOString(), created_at: new Date(t.createdAt).toISOString(),
updated_at: new Date(t.updatedAt).toISOString(), updated_at: new Date(t.updatedAt).toISOString(),
...(t.clickup ? { clickup: { taskId: t.clickup.taskId, url: t.clickup.url, syncStatus: t.clickup.syncStatus } } : {}),
})); }));
// Sort by status, then sort_order, then created_at DESC // Sort by status, then sort_order, then created_at DESC
@ -458,6 +463,294 @@ routes.get("/api/spaces/:slug/activity", async (c) => {
return c.json([]); return c.json([]);
}); });
// ── ClickUp integration helpers ──
function getClickUpConnection(space: string): ClickUpConnectionDoc | null {
if (!_syncServer) return null;
return _syncServer.getDoc<ClickUpConnectionDoc>(clickupConnectionDocId(space)) ?? null;
}
function getAccessToken(space: string): string | null {
const conn = getClickUpConnection(space);
return conn?.clickup?.accessToken || null;
}
// ── API: ClickUp Integration ──
// GET /api/clickup/status — connection status
routes.get("/api/clickup/status", async (c) => {
const space = c.req.param("space") || "demo";
const conn = getClickUpConnection(space);
if (!conn?.clickup) return c.json({ connected: false });
// Count synced boards
const boardDocIds = getBoardDocIds(space);
let syncedBoards = 0;
let pendingTasks = 0;
for (const docId of boardDocIds) {
const doc = _syncServer!.getDoc<BoardDoc>(docId);
if (doc?.board?.clickup?.syncEnabled) {
syncedBoards++;
for (const task of Object.values(doc.tasks)) {
if (task.clickup && task.clickup.syncStatus !== 'synced') pendingTasks++;
}
}
}
return c.json({
connected: true,
teamId: conn.clickup.teamId,
teamName: conn.clickup.teamName,
connectedAt: conn.clickup.connectedAt,
syncedBoards,
pendingTasks,
});
});
// POST /api/clickup/connect-token — connect via personal API token
routes.post("/api/clickup/connect-token", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const space = c.req.param("space") || "demo";
const body = await c.req.json();
const apiToken = body.token;
if (!apiToken) return c.json({ error: "ClickUp API token required" }, 400);
// Verify token by fetching teams
const client = new ClickUpClient(apiToken);
let teams: any[];
try {
teams = await client.getTeams();
} catch (err) {
return c.json({ error: "Invalid ClickUp API token" }, 400);
}
const team = teams[0];
if (!team) return c.json({ error: "No ClickUp workspaces found" }, 400);
// Generate webhook secret
const secretBuf = new Uint8Array(32);
crypto.getRandomValues(secretBuf);
const webhookSecret = Array.from(secretBuf).map(b => b.toString(16).padStart(2, '0')).join('');
// Store connection
const docId = clickupConnectionDocId(space);
let connDoc = _syncServer!.getDoc<ClickUpConnectionDoc>(docId);
if (!connDoc) {
connDoc = Automerge.change(Automerge.init<ClickUpConnectionDoc>(), 'init clickup connection', (d) => {
d.meta = { module: 'tasks', collection: 'clickup-connection', version: 1, spaceSlug: space, createdAt: Date.now() };
});
_syncServer!.setDoc(docId, connDoc);
}
_syncServer!.changeDoc<ClickUpConnectionDoc>(docId, 'Connect ClickUp via API token', (d) => {
d.clickup = {
accessToken: apiToken,
teamId: team.id,
teamName: team.name,
connectedAt: Date.now(),
webhookSecret,
};
});
return c.json({ ok: true, teamId: team.id, teamName: team.name });
});
// POST /api/clickup/disconnect — disconnect + cleanup webhook
routes.post("/api/clickup/disconnect", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const space = c.req.param("space") || "demo";
const conn = getClickUpConnection(space);
if (conn?.clickup) {
// Cleanup webhooks on synced boards
const client = new ClickUpClient(conn.clickup.accessToken);
const boardDocIds = getBoardDocIds(space);
for (const docId of boardDocIds) {
const doc = _syncServer!.getDoc<BoardDoc>(docId);
if (doc?.board?.clickup?.webhookId) {
try { await client.deleteWebhook(doc.board.clickup.webhookId); } catch {}
_syncServer!.changeDoc<BoardDoc>(docId, 'Remove ClickUp sync', (d) => {
delete d.board.clickup;
});
}
}
// Remove connection
_syncServer!.changeDoc<ClickUpConnectionDoc>(clickupConnectionDocId(space), 'Disconnect ClickUp', (d) => {
delete d.clickup;
});
}
return c.json({ ok: true });
});
// GET /api/clickup/workspaces — list ClickUp teams
routes.get("/api/clickup/workspaces", async (c) => {
const space = c.req.param("space") || "demo";
const accessToken = getAccessToken(space);
if (!accessToken) return c.json({ error: "Not connected to ClickUp" }, 400);
const client = new ClickUpClient(accessToken);
const teams = await client.getTeams();
return c.json(teams.map((t: any) => ({ id: t.id, name: t.name, members: t.members?.length || 0 })));
});
// GET /api/clickup/spaces/:teamId — list ClickUp spaces
routes.get("/api/clickup/spaces/:teamId", async (c) => {
const space = c.req.param("space") || "demo";
const accessToken = getAccessToken(space);
if (!accessToken) return c.json({ error: "Not connected to ClickUp" }, 400);
const teamId = c.req.param("teamId");
const client = new ClickUpClient(accessToken);
const spaces = await client.getSpaces(teamId);
return c.json(spaces.map((s: any) => ({ id: s.id, name: s.name })));
});
// GET /api/clickup/lists/:spaceId — list all lists in a space (including folders)
routes.get("/api/clickup/lists/:spaceId", async (c) => {
const space = c.req.param("space") || "demo";
const accessToken = getAccessToken(space);
if (!accessToken) return c.json({ error: "Not connected to ClickUp" }, 400);
const spaceId = c.req.param("spaceId");
const client = new ClickUpClient(accessToken);
// Get folderless lists + lists inside folders
const [folderlessLists, folders] = await Promise.all([
client.getFolderlessLists(spaceId),
client.getFolders(spaceId),
]);
const lists: any[] = folderlessLists.map((l: any) => ({
id: l.id, name: l.name, taskCount: l.task_count || 0, folder: null,
}));
for (const folder of folders) {
const folderLists = folder.lists || [];
for (const l of folderLists) {
lists.push({ id: l.id, name: l.name, taskCount: l.task_count || 0, folder: folder.name });
}
}
return c.json(lists);
});
// POST /api/clickup/import — import a ClickUp list → rTasks board
routes.post("/api/clickup/import", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const space = c.req.param("space") || "demo";
const accessToken = getAccessToken(space);
if (!accessToken) return c.json({ error: "Not connected to ClickUp" }, 400);
const body = await c.req.json();
const { listId, boardSlug, enableSync } = body;
if (!listId) return c.json({ error: "listId required" }, 400);
const slug = boardSlug || `clickup-${listId}`;
try {
const result = await importClickUpList(_syncServer!, space, slug, listId, accessToken, {
enableSync: enableSync ?? false,
createNew: !boardSlug,
});
// Register webhook if sync enabled
if (enableSync) {
const conn = getClickUpConnection(space);
if (conn?.clickup) {
const client = new ClickUpClient(accessToken);
const host = c.req.header('host') || 'rspace.online';
const protocol = c.req.header('x-forwarded-proto') || 'https';
const endpoint = `${protocol}://${host}/${space}/rtasks/api/clickup/webhook`;
try {
const wh = await client.createWebhook(
conn.clickup.teamId,
endpoint,
['taskCreated', 'taskUpdated', 'taskStatusUpdated', 'taskDeleted'],
conn.clickup.webhookSecret,
);
// Store webhook ID on board
_syncServer!.changeDoc<BoardDoc>(boardDocId(space, slug), 'Store webhook ID', (d) => {
if (d.board.clickup) d.board.clickup.webhookId = wh.id;
});
} catch (err) {
console.error('[ClickUp] Failed to create webhook:', err);
}
}
}
return c.json(result, 201);
} catch (err: any) {
return c.json({ error: err.message || 'Import failed' }, 500);
}
});
// POST /api/clickup/push-board/:slug — export rTasks board → ClickUp list
routes.post("/api/clickup/push-board/:slug", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const space = c.req.param("space") || "demo";
const accessToken = getAccessToken(space);
if (!accessToken) return c.json({ error: "Not connected to ClickUp" }, 400);
const slug = c.req.param("slug");
const body = await c.req.json();
const { listId } = body;
if (!listId) return c.json({ error: "listId required" }, 400);
try {
const result = await pushBoardToClickUp(_syncServer!, space, slug, listId, accessToken);
return c.json(result);
} catch (err: any) {
return c.json({ error: err.message || 'Push failed' }, 500);
}
});
// POST /api/clickup/sync-board/:slug — toggle two-way sync on/off
routes.post("/api/clickup/sync-board/:slug", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const space = c.req.param("space") || "demo";
const slug = c.req.param("slug");
const body = await c.req.json();
const { enabled } = body;
const docId = boardDocId(space, slug);
const doc = _syncServer!.getDoc<BoardDoc>(docId);
if (!doc) return c.json({ error: "Board not found" }, 404);
if (!doc.board.clickup) return c.json({ error: "Board not connected to ClickUp" }, 400);
_syncServer!.changeDoc<BoardDoc>(docId, `Toggle ClickUp sync ${enabled ? 'on' : 'off'}`, (d) => {
if (d.board.clickup) d.board.clickup.syncEnabled = !!enabled;
});
return c.json({ ok: true, syncEnabled: !!enabled });
});
// POST /api/clickup/webhook — receive ClickUp webhook events (public, no auth)
routes.post("/api/clickup/webhook", async (c) => {
const space = c.req.param("space") || "demo";
const body = await c.req.json();
const signature = c.req.header('x-signature') || null;
const result = await handleClickUpWebhook(_syncServer!, space, body, signature);
return c.json(result, result.ok ? 200 : 400);
});
// ── Page route ── // ── Page route ──
routes.get("/", (c) => { routes.get("/", (c) => {
const space = c.req.param("space") || "demo"; const space = c.req.param("space") || "demo";
@ -469,7 +762,7 @@ routes.get("/", (c) => {
modules: getModuleInfoList(), modules: getModuleInfoList(),
theme: "dark", theme: "dark",
body: `<folk-tasks-board space="${space}"></folk-tasks-board>`, body: `<folk-tasks-board space="${space}"></folk-tasks-board>`,
scripts: `<script type="module" src="/modules/rtasks/folk-tasks-board.js?v=3"></script>`, scripts: `<script type="module" src="/modules/rtasks/folk-tasks-board.js?v=4"></script>`,
styles: `<link rel="stylesheet" href="/modules/rtasks/tasks.css">`, styles: `<link rel="stylesheet" href="/modules/rtasks/tasks.css">`,
})); }));
}); });
@ -480,7 +773,13 @@ export const tasksModule: RSpaceModule = {
icon: "📋", icon: "📋",
description: "Kanban workspace boards for collaborative task management", description: "Kanban workspace boards for collaborative task management",
scoping: { defaultScope: 'space', userConfigurable: false }, scoping: { defaultScope: 'space', userConfigurable: false },
docSchemas: [{ pattern: '{space}:tasks:boards:{boardId}', description: 'Kanban board with tasks', init: boardSchema.init }], docSchemas: [
{ pattern: '{space}:tasks:boards:{boardId}', description: 'Kanban board with tasks', init: boardSchema.init },
{ pattern: '{space}:tasks:clickup-connection', description: 'ClickUp integration credentials', init: clickupConnectionSchema.init },
],
settingsSchema: [
{ key: 'clickupApiToken', label: 'ClickUp API Token', type: 'password', description: 'Personal API token from ClickUp Settings > Apps (pk_...)' },
],
routes, routes,
standaloneDomain: "rtasks.online", standaloneDomain: "rtasks.online",
landingPage: renderLanding, landingPage: renderLanding,
@ -492,6 +791,7 @@ export const tasksModule: RSpaceModule = {
_syncServer = ctx.syncServer; _syncServer = ctx.syncServer;
seedDemoIfEmpty(); seedDemoIfEmpty();
seedBCRGTasksIfEmpty('demo'); seedBCRGTasksIfEmpty('demo');
initClickUpSync(ctx.syncServer);
}, },
async onSpaceCreate(ctx: SpaceLifecycleContext) { async onSpaceCreate(ctx: SpaceLifecycleContext) {
if (!_syncServer) return; if (!_syncServer) return;

View File

@ -9,6 +9,16 @@ import type { DocSchema } from '../../shared/local-first/document';
// ── Document types ── // ── Document types ──
export interface ClickUpTaskMeta {
taskId: string; // ClickUp task ID
listId: string; // ClickUp list ID
url: string; // Direct link to ClickUp task
lastSyncedAt: number;
syncStatus: 'synced' | 'pending-push' | 'conflict' | 'push-failed';
contentHash: string; // SHA of title+desc+status+priority at last sync
remoteHash?: string; // ClickUp state hash at last pull
}
export interface TaskItem { export interface TaskItem {
id: string; id: string;
spaceId: string; spaceId: string;
@ -22,6 +32,17 @@ export interface TaskItem {
sortOrder: number; sortOrder: number;
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
clickup?: ClickUpTaskMeta;
}
export interface ClickUpBoardMeta {
listId: string; // ClickUp list this board mirrors
listName: string;
workspaceId: string; // ClickUp team ID
webhookId?: string; // For teardown on disconnect
syncEnabled: boolean;
statusMap: Record<string, string>; // rTasks → ClickUp
reverseStatusMap: Record<string, string>; // ClickUp → rTasks
} }
export interface BoardMeta { export interface BoardMeta {
@ -35,6 +56,7 @@ export interface BoardMeta {
labels: string[]; labels: string[];
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
clickup?: ClickUpBoardMeta;
} }
export interface BoardDoc { export interface BoardDoc {
@ -85,6 +107,44 @@ export function boardDocId(space: string, boardId: string) {
return `${space}:tasks:boards:${boardId}` as const; return `${space}:tasks:boards:${boardId}` as const;
} }
// ── ClickUp connection doc (one per space) ──
export interface ClickUpConnectionDoc {
meta: {
module: string;
collection: string;
version: number;
spaceSlug: string;
createdAt: number;
};
clickup?: {
accessToken: string; // OAuth token or personal API token
teamId: string;
teamName: string;
connectedAt: number;
webhookSecret: string; // HMAC secret for webhook validation
};
}
export const clickupConnectionSchema: DocSchema<ClickUpConnectionDoc> = {
module: 'tasks',
collection: 'clickup-connection',
version: 1,
init: (): ClickUpConnectionDoc => ({
meta: {
module: 'tasks',
collection: 'clickup-connection',
version: 1,
spaceSlug: '',
createdAt: Date.now(),
},
}),
};
export function clickupConnectionDocId(space: string) {
return `${space}:tasks:clickup-connection` as const;
}
export function createTaskItem( export function createTaskItem(
id: string, id: string,
spaceId: string, spaceId: string,

View File

@ -92,6 +92,7 @@ import { backupRouter } from "./local-first/backup-routes";
import { oauthRouter } from "./oauth/index"; import { oauthRouter } from "./oauth/index";
import { setNotionOAuthSyncServer } from "./oauth/notion"; import { setNotionOAuthSyncServer } from "./oauth/notion";
import { setGoogleOAuthSyncServer } from "./oauth/google"; import { setGoogleOAuthSyncServer } from "./oauth/google";
import { setClickUpOAuthSyncServer } from "./oauth/clickup";
import { notificationRouter } from "./notification-routes"; import { notificationRouter } from "./notification-routes";
import { registerUserConnection, unregisterUserConnection, notify } from "./notification-service"; import { registerUserConnection, unregisterUserConnection, notify } from "./notification-service";
import { SystemClock } from "./clock-service"; import { SystemClock } from "./clock-service";
@ -2493,7 +2494,8 @@ for (const mod of getAllModules()) {
|| pathname.includes("/rcart/pay/") || pathname.includes("/rcart/pay/")
|| pathname.includes("/rwallet/api/") || pathname.includes("/rwallet/api/")
|| pathname.includes("/rdesign/api/") || pathname.includes("/rdesign/api/")
|| (c.req.method === "GET" && pathname.includes("/rvote/api/")); || (c.req.method === "GET" && pathname.includes("/rvote/api/"))
|| (c.req.method === "POST" && pathname.endsWith("/rtasks/api/clickup/webhook"));
if (!isHtmlRequest && !isPublicEndpoint && (vis === "private" || vis === "permissioned")) { if (!isHtmlRequest && !isPublicEndpoint && (vis === "private" || vis === "permissioned")) {
const token = extractToken(c.req.raw.headers); const token = extractToken(c.req.raw.headers);
@ -3630,6 +3632,7 @@ const server = Bun.serve<WSData>({
// Pass syncServer to OAuth handlers // Pass syncServer to OAuth handlers
setNotionOAuthSyncServer(syncServer); setNotionOAuthSyncServer(syncServer);
setGoogleOAuthSyncServer(syncServer); setGoogleOAuthSyncServer(syncServer);
setClickUpOAuthSyncServer(syncServer);
})(); })();
// Ensure generated files directory exists // Ensure generated files directory exists

147
server/oauth/clickup.ts Normal file
View File

@ -0,0 +1,147 @@
/**
* ClickUp OAuth2 flow.
*
* GET /authorize?space=X redirect to ClickUp
* GET /callback exchange code, store token, redirect back
* POST /disconnect?space=X revoke token
*/
import { Hono } from 'hono';
import * as Automerge from '@automerge/automerge';
import { clickupConnectionDocId } from '../../modules/rtasks/schemas';
import type { ClickUpConnectionDoc } from '../../modules/rtasks/schemas';
import type { SyncServer } from '../local-first/sync-server';
const clickupOAuthRoutes = new Hono();
const CLICKUP_CLIENT_ID = process.env.CLICKUP_CLIENT_ID || '';
const CLICKUP_CLIENT_SECRET = process.env.CLICKUP_CLIENT_SECRET || '';
const CLICKUP_REDIRECT_URI = process.env.CLICKUP_REDIRECT_URI || '';
let _syncServer: SyncServer | null = null;
export function setClickUpOAuthSyncServer(ss: SyncServer) {
_syncServer = ss;
}
function ensureConnectionDoc(space: string): ClickUpConnectionDoc {
if (!_syncServer) throw new Error('SyncServer not initialized');
const docId = clickupConnectionDocId(space);
let doc = _syncServer.getDoc<ClickUpConnectionDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<ClickUpConnectionDoc>(), 'init clickup connection', (d) => {
d.meta = {
module: 'tasks',
collection: 'clickup-connection',
version: 1,
spaceSlug: space,
createdAt: Date.now(),
};
});
_syncServer.setDoc(docId, doc);
}
return doc;
}
// GET /authorize — redirect to ClickUp OAuth
clickupOAuthRoutes.get('/authorize', (c) => {
const space = c.req.query('space');
if (!space) return c.json({ error: 'space query param required' }, 400);
if (!CLICKUP_CLIENT_ID) return c.json({ error: 'ClickUp OAuth not configured' }, 500);
const state = Buffer.from(JSON.stringify({ space })).toString('base64url');
const url = `https://app.clickup.com/api?client_id=${CLICKUP_CLIENT_ID}&redirect_uri=${encodeURIComponent(CLICKUP_REDIRECT_URI)}&state=${state}`;
return c.redirect(url);
});
// GET /callback — exchange code for token
clickupOAuthRoutes.get('/callback', async (c) => {
const code = c.req.query('code');
const stateParam = c.req.query('state');
if (!code || !stateParam) return c.json({ error: 'Missing code or state' }, 400);
let state: { space: string };
try {
state = JSON.parse(Buffer.from(stateParam, 'base64url').toString());
} catch {
return c.json({ error: 'Invalid state parameter' }, 400);
}
// Exchange code for access token
const tokenRes = await fetch('https://api.clickup.com/api/v2/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_id: CLICKUP_CLIENT_ID,
client_secret: CLICKUP_CLIENT_SECRET,
code,
}),
});
if (!tokenRes.ok) {
const err = await tokenRes.text();
return c.json({ error: `Token exchange failed: ${err}` }, 502);
}
const tokenData = await tokenRes.json() as any;
const accessToken = tokenData.access_token;
// Fetch workspace info to store team ID
const teamsRes = await fetch('https://api.clickup.com/api/v2/team', {
headers: { 'Authorization': accessToken },
});
let teamId = '';
let teamName = 'ClickUp Workspace';
if (teamsRes.ok) {
const teamsData = await teamsRes.json() as any;
if (teamsData.teams?.length > 0) {
teamId = teamsData.teams[0].id;
teamName = teamsData.teams[0].name;
}
}
// Generate webhook secret
const secretBuf = new Uint8Array(32);
crypto.getRandomValues(secretBuf);
const webhookSecret = Array.from(secretBuf).map(b => b.toString(16).padStart(2, '0')).join('');
// Store token in Automerge connections doc
ensureConnectionDoc(state.space);
const docId = clickupConnectionDocId(state.space);
_syncServer!.changeDoc<ClickUpConnectionDoc>(docId, 'Connect ClickUp', (d) => {
d.clickup = {
accessToken,
teamId,
teamName,
connectedAt: Date.now(),
webhookSecret,
};
});
// Redirect back to rTasks
const redirectUrl = `/${state.space}/rtasks?connected=clickup`;
return c.redirect(redirectUrl);
});
// POST /disconnect — remove token
clickupOAuthRoutes.post('/disconnect', async (c) => {
const space = c.req.query('space');
if (!space) return c.json({ error: 'space query param required' }, 400);
const docId = clickupConnectionDocId(space);
const doc = _syncServer?.getDoc<ClickUpConnectionDoc>(docId);
if (doc?.clickup) {
_syncServer!.changeDoc<ClickUpConnectionDoc>(docId, 'Disconnect ClickUp', (d) => {
delete d.clickup;
});
}
return c.json({ ok: true });
});
export { clickupOAuthRoutes };

View File

@ -4,6 +4,7 @@
* Provides OAuth2 authorize/callback/disconnect flows for: * Provides OAuth2 authorize/callback/disconnect flows for:
* - Notion (workspace-level integration) * - Notion (workspace-level integration)
* - Google (user-level, with token refresh) * - Google (user-level, with token refresh)
* - ClickUp (workspace-level, task sync)
* *
* Tokens are stored in Automerge docs per space via SyncServer. * Tokens are stored in Automerge docs per space via SyncServer.
*/ */
@ -11,10 +12,12 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { notionOAuthRoutes } from './notion'; import { notionOAuthRoutes } from './notion';
import { googleOAuthRoutes } from './google'; import { googleOAuthRoutes } from './google';
import { clickupOAuthRoutes } from './clickup';
const oauthRouter = new Hono(); const oauthRouter = new Hono();
oauthRouter.route('/notion', notionOAuthRoutes); oauthRouter.route('/notion', notionOAuthRoutes);
oauthRouter.route('/google', googleOAuthRoutes); oauthRouter.route('/google', googleOAuthRoutes);
oauthRouter.route('/clickup', clickupOAuthRoutes);
export { oauthRouter }; export { oauthRouter };

View File

@ -696,21 +696,21 @@ const OVERLAY_CSS = `
gap: 4px; gap: 4px;
padding: 4px 10px 4px 6px; padding: 4px 10px 4px 6px;
border-radius: 16px; border-radius: 16px;
background: var(--rs-bg-secondary, rgba(30, 30, 30, 0.85)); background: var(--rs-glass-bg, rgba(15, 23, 42, 0.85));
-webkit-backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
font-size: 11px; font-size: 11px;
color: var(--rs-text-secondary, #ccc); color: var(--rs-text-secondary, #94a3b8);
pointer-events: auto; pointer-events: auto;
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
border: 1px solid var(--rs-border, rgba(255,255,255,0.08)); border: 1px solid var(--rs-glass-border, rgba(255,255,255,0.08));
transition: opacity 0.2s, border-color 0.2s; transition: opacity 0.2s, border-color 0.2s;
white-space: nowrap; white-space: nowrap;
} }
.collab-badge:hover { .collab-badge:hover {
border-color: rgba(255,255,255,0.2); border-color: var(--rs-border-strong, rgba(255,255,255,0.2));
} }
.collab-badge.visible { .collab-badge.visible {
@ -748,7 +748,7 @@ const OVERLAY_CSS = `
max-height: calc(100vh - 120px); max-height: calc(100vh - 120px);
background: var(--rs-bg-surface, #1e1e1e); background: var(--rs-bg-surface, #1e1e1e);
border-radius: 12px; border-radius: 12px;
box-shadow: 0 8px 32px rgba(0,0,0,0.4), 0 0 0 1px rgba(255,255,255,0.08); box-shadow: var(--rs-shadow-lg, 0 8px 30px rgba(0,0,0,0.4)), 0 0 0 1px var(--rs-glass-border, rgba(255,255,255,0.08));
z-index: 10001; z-index: 10001;
overflow: hidden; overflow: hidden;
pointer-events: auto; pointer-events: auto;
@ -761,7 +761,7 @@ const OVERLAY_CSS = `
.people-panel-header { .people-panel-header {
padding: 12px 16px; padding: 12px 16px;
border-bottom: 1px solid var(--rs-border-subtle, rgba(255,255,255,0.06)); border-bottom: 1px solid var(--rs-border-subtle, rgba(0,0,0,0.06));
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
@ -770,15 +770,15 @@ const OVERLAY_CSS = `
.people-panel-header h3 { .people-panel-header h3 {
font-size: 14px; font-size: 14px;
color: var(--rs-text-primary, #fff); color: var(--rs-text-primary, #0f172a);
margin: 0; margin: 0;
font-weight: 600; font-weight: 600;
} }
.panel-count { .panel-count {
font-size: 12px; font-size: 12px;
color: var(--rs-text-secondary, #ccc); color: var(--rs-text-secondary, #374151);
background: var(--rs-bg-surface-raised, rgba(255,255,255,0.06)); background: var(--rs-bg-surface-raised, #f0efe9);
padding: 2px 8px; padding: 2px 8px;
border-radius: 10px; border-radius: 10px;
} }
@ -881,17 +881,17 @@ const OVERLAY_CSS = `
.actions-btn { .actions-btn {
padding: 2px 8px; padding: 2px 8px;
border: 1px solid var(--rs-border, rgba(255,255,255,0.12)); border: 1px solid var(--rs-border, rgba(0,0,0,0.1));
border-radius: 6px; border-radius: 6px;
background: var(--rs-bg-surface, #1e1e1e); background: var(--rs-bg-surface-raised, #334155);
cursor: pointer; cursor: pointer;
font-size: 12px; font-size: 12px;
color: var(--rs-text-muted, #888); color: var(--rs-text-muted, #64748b);
transition: background 0.15s; transition: background 0.15s;
} }
.actions-btn:hover { .actions-btn:hover {
background: var(--rs-bg-hover, rgba(255,255,255,0.08)); background: var(--rs-bg-hover, rgba(0,0,0,0.04));
} }
.people-actions { .people-actions {