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:
parent
eea3443cba
commit
41051715b9
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 > 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;
|
||||||
|
|
|
||||||
|
|
@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
|
@ -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' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue