diff --git a/modules/crowdsurf/components/folk-crowdsurf-dashboard.ts b/modules/crowdsurf/components/folk-crowdsurf-dashboard.ts index 0f8b9e4..f5f265d 100644 --- a/modules/crowdsurf/components/folk-crowdsurf-dashboard.ts +++ b/modules/crowdsurf/components/folk-crowdsurf-dashboard.ts @@ -92,7 +92,7 @@ class FolkCrowdSurfDashboard extends HTMLElement { } else { 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() { diff --git a/modules/rcal/mod.ts b/modules/rcal/mod.ts index 91badcc..d63e85b 100644 --- a/modules/rcal/mod.ts +++ b/modules/rcal/mod.ts @@ -1058,9 +1058,10 @@ function migrateTagsField(space: string) { }; _syncServer.changeDoc(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)) { - (ev as any).tags = demoTags[ev.title] || null; + ev.tags = demoTags[ev.title] || null; } } }); diff --git a/modules/rnotes/components/folk-notes-app.ts b/modules/rnotes/components/folk-notes-app.ts index d51c063..8aa45f3 100644 --- a/modules/rnotes/components/folk-notes-app.ts +++ b/modules/rnotes/components/folk-notes-app.ts @@ -1478,8 +1478,8 @@ Gear: EUR 400 (10%)

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

Maya is tracking expenses in rF sharedBroadcastPresence({ module: 'rnotes', context: this.selectedNote - ? `${this.selectedNotebook?.name || 'Notebook'} > ${this.selectedNote.title}` - : this.selectedNotebook?.name || '', + ? `${this.selectedNotebook?.title || 'Notebook'} > ${this.selectedNote.title}` + : this.selectedNotebook?.title || '', notebookId: this.selectedNotebook?.id, noteId: this.selectedNote?.id, }); diff --git a/modules/rtasks/components/folk-tasks-board.ts b/modules/rtasks/components/folk-tasks-board.ts index aad422c..799c568 100644 --- a/modules/rtasks/components/folk-tasks-board.ts +++ b/modules/rtasks/components/folk-tasks-board.ts @@ -34,6 +34,20 @@ class FolkTasksBoard extends HTMLElement { private _history = new ViewHistory<"list" | "board">("list"); private _backlogTaskId: string | 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 static readonly TOUR_STEPS = [ { 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 { this.subscribeOffline(); this.loadWorkspaces(); + this.loadClickUpStatus(); this.render(); } if (!localStorage.getItem("rtasks_tour_done")) { @@ -162,11 +177,219 @@ class FolkTasksBoard extends HTMLElement { if (spaceRes.ok) { const space = await spaceRes.json(); if (space.statuses?.length) this.statuses = space.statuses; + this._boardClickup = space.clickup || null; } } catch { this.tasks = []; } 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 ` +

+

Connect ClickUp

+

Enter your ClickUp Personal API Token (Settings > Apps)

+ +
+ + +
+
`; + } + + if (this._cuStep === 'workspace') { + return ` +
+

Select Workspace

+ ${this._cuWorkspaces.map(w => ` +
+ ${this.esc(w.name)} + ${w.members || 0} members +
+ `).join('')} + ${this._cuSpaces.length > 0 ? ` +

Select Space

+ ${this._cuSpaces.map(s => ` +
+ ${this.esc(s.name)} +
+ `).join('')} + ` : ''} +
+ +
+
`; + } + + if (this._cuStep === 'list') { + return ` +
+

Select List to Import

+ ${this._cuLists.map(l => ` +
+
+ ${this.esc(l.name)} + ${l.folder ? ` in ${this.esc(l.folder)}` : ''} +
+ ${l.taskCount} tasks +
+ `).join('')} +
+ +
+
+ + + +
+
`; + } + + if (this._cuStep === 'importing') { + return `
Importing tasks from ClickUp...
`; + } + + if (this._cuStep === 'done' && this._cuImportResult) { + return ` +
+

Import Complete

+

+ Imported ${this._cuImportResult.taskCount} tasks into board ${this.esc(this._cuImportResult.boardId)} +

+
+ + +
+
`; + } + + // Connected state — show disconnect option + if (this._cuConnected) { + return ` +
+

ClickUp Connected

+

Workspace: ${this.esc(this._cuTeamName)}

+
+ + + +
+
`; + } + + return ''; + } + private async createWorkspace() { const name = prompt("Workspace name:"); 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-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) { .board { flex-direction: column; overflow-x: visible; } .column { min-width: 100%; max-width: 100%; } @@ -425,9 +678,11 @@ class FolkTasksBoard extends HTMLElement { return `
Workspaces + ${!this.isDemo ? `` : ''}
+ ${this.renderClickUpPanel()} ${this.workspaces.length > 0 ? `
${this.workspaces.map(ws => `
@@ -461,11 +716,17 @@ class FolkTasksBoard extends HTMLElement {
`; } + private _boardClickup: { listName?: string; syncEnabled?: boolean } | null = null; + private renderBoard(): string { + const cuSyncInfo = this._boardClickup?.syncEnabled + ? ` CU ${this.esc(this._boardClickup.listName || '')}` + : ''; return `
${this._history.canGoBack ? '' : ''} ${this.esc(this.workspaceSlug)} + ${cuSyncInfo}
@@ -537,11 +798,14 @@ class FolkTasksBoard extends HTMLElement { const map: Record = { URGENT: "badge-urgent", HIGH: "badge-high", MEDIUM: "badge-medium", LOW: "badge-low" }; return map[p] ? `${this.esc(p.toLowerCase())}` : ""; }; + const cuBadge = task.clickup + ? `CU ` + : ''; return `
${isEditing ? `` - : `
${this.esc(task.title)}
`} + : `
${this.esc(task.title)} ${cuBadge}
`}
${priorityBadge(task.priority || "")} ${(task.labels || []).map((l: string) => `${this.esc(l)}`).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) this.shadow.querySelectorAll(".task-card[draggable]").forEach(card => { const el = card as HTMLElement; diff --git a/modules/rtasks/lib/clickup-client.ts b/modules/rtasks/lib/clickup-client.ts new file mode 100644 index 0000000..177e801 --- /dev/null +++ b/modules/rtasks/lib/clickup-client.ts @@ -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(); + +async function waitForSlot(token: string): Promise { + 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, +): Promise { + await waitForSlot(token); + + const url = `${CLICKUP_API}${path}`; + const headers: Record = { + '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 { + const data = await request(this.token, 'GET', '/team'); + return data.teams || []; + } + + // ── Spaces ── + + async getSpaces(teamId: string): Promise { + const data = await request(this.token, 'GET', `/team/${teamId}/space?archived=false`); + return data.spaces || []; + } + + // ── Folders & Lists ── + + async getFolders(spaceId: string): Promise { + const data = await request(this.token, 'GET', `/space/${spaceId}/folder?archived=false`); + return data.folders || []; + } + + async getFolderlessLists(spaceId: string): Promise { + const data = await request(this.token, 'GET', `/space/${spaceId}/list?archived=false`); + return data.lists || []; + } + + async getListsInFolder(folderId: string): Promise { + const data = await request(this.token, 'GET', `/folder/${folderId}/list?archived=false`); + return data.lists || []; + } + + // ── Tasks ── + + async getTasks(listId: string, page = 0): Promise { + 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 { + return request(this.token, 'GET', `/task/${taskId}`); + } + + async createTask(listId: string, body: Record): Promise { + return request(this.token, 'POST', `/list/${listId}/task`, body); + } + + async updateTask(taskId: string, body: Record): Promise { + return request(this.token, 'PUT', `/task/${taskId}`, body); + } + + // ── Webhooks ── + + async createWebhook(teamId: string, endpoint: string, events: string[], secret?: string): Promise { + const body: Record = { endpoint, events }; + if (secret) body.secret = secret; + return request(this.token, 'POST', `/team/${teamId}/webhook`, body); + } + + async deleteWebhook(webhookId: string): Promise { + await request(this.token, 'DELETE', `/webhook/${webhookId}`); + } + + async getWebhooks(teamId: string): Promise { + const data = await request(this.token, 'GET', `/team/${teamId}/webhook`); + return data.webhooks || []; + } + + // ── List details (for status mapping) ── + + async getList(listId: string): Promise { + return request(this.token, 'GET', `/list/${listId}`); + } +} diff --git a/modules/rtasks/lib/clickup-mapping.ts b/modules/rtasks/lib/clickup-mapping.ts new file mode 100644 index 0000000..b0879e5 --- /dev/null +++ b/modules/rtasks/lib/clickup-mapping.ts @@ -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 = { + TODO: 'to do', + IN_PROGRESS: 'in progress', + REVIEW: 'in review', + DONE: 'complete', +}; + +const DEFAULT_CLICKUP_TO_STATUS: Record = { + '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 { + if (customMap?.[rTasksStatus]) return customMap[rTasksStatus]; + return DEFAULT_STATUS_TO_CLICKUP[rTasksStatus] || rTasksStatus.toLowerCase().replace(/_/g, ' '); +} + +export function fromClickUpStatus( + clickUpStatus: string, + reverseMap?: Record, +): 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 = { + URGENT: 1, + HIGH: 2, + MEDIUM: 3, + LOW: 4, +}; + +const CLICKUP_TO_PRIORITY: Record = { + 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 { + 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): { + 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, +): Record { + const body: Record = { + 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; + reverseStatusMap: Record; +} { + const statusMap: Record = {}; + const reverseStatusMap: Record = {}; + + 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 }; +} diff --git a/modules/rtasks/lib/clickup-sync.ts b/modules/rtasks/lib/clickup-sync.ts new file mode 100644 index 0000000..47cfb75 --- /dev/null +++ b/modules/rtasks/lib/clickup-sync.ts @@ -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 | null = null; + +// Track last-pushed hashes to avoid re-pushing unchanged tasks +const lastPushedHash = new Map(); + +// ── 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(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(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(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(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(docId); + + if (!doc || opts.createNew) { + // Create new board + const Automerge = await import('@automerge/automerge'); + const now = Date.now(); + doc = Automerge.change(Automerge.init(), '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(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(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, +): Promise<{ pushed: number }> { + const client = new ClickUpClient(accessToken); + const docId = boardDocId(space, boardSlug); + const doc = syncServer.getDoc(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(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(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(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(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(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(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(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(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' }; + } +} diff --git a/modules/rtasks/mod.ts b/modules/rtasks/mod.ts index 68b0044..aa92874 100644 --- a/modules/rtasks/mod.ts +++ b/modules/rtasks/mod.ts @@ -16,8 +16,11 @@ import type { RSpaceModule, SpaceLifecycleContext } from "../../shared/module"; import { verifyToken, extractToken } from "../../server/auth"; import { renderLanding } from "./landing"; import type { SyncServer } from '../../server/local-first/sync-server'; -import { boardSchema, boardDocId, createTaskItem } from './schemas'; -import type { BoardDoc, TaskItem, BoardMeta } from './schemas'; +import { boardSchema, boardDocId, createTaskItem, clickupConnectionDocId, clickupConnectionSchema } 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 @@ -277,6 +280,7 @@ routes.get("/api/spaces/:slug", async (c) => { labels: doc.board.labels, created_at: new Date(doc.board.createdAt).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, created_at: new Date(t.createdAt).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 @@ -458,6 +463,294 @@ routes.get("/api/spaces/:slug/activity", async (c) => { return c.json([]); }); +// ── ClickUp integration helpers ── + +function getClickUpConnection(space: string): ClickUpConnectionDoc | null { + if (!_syncServer) return null; + return _syncServer.getDoc(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(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(docId); + if (!connDoc) { + connDoc = Automerge.change(Automerge.init(), 'init clickup connection', (d) => { + d.meta = { module: 'tasks', collection: 'clickup-connection', version: 1, spaceSlug: space, createdAt: Date.now() }; + }); + _syncServer!.setDoc(docId, connDoc); + } + + _syncServer!.changeDoc(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(docId); + if (doc?.board?.clickup?.webhookId) { + try { await client.deleteWebhook(doc.board.clickup.webhookId); } catch {} + _syncServer!.changeDoc(docId, 'Remove ClickUp sync', (d) => { + delete d.board.clickup; + }); + } + } + + // Remove connection + _syncServer!.changeDoc(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(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(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(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 ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; @@ -469,7 +762,7 @@ routes.get("/", (c) => { modules: getModuleInfoList(), theme: "dark", body: ``, - scripts: ``, + scripts: ``, styles: ``, })); }); @@ -480,7 +773,13 @@ export const tasksModule: RSpaceModule = { icon: "📋", description: "Kanban workspace boards for collaborative task management", 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, standaloneDomain: "rtasks.online", landingPage: renderLanding, @@ -492,6 +791,7 @@ export const tasksModule: RSpaceModule = { _syncServer = ctx.syncServer; seedDemoIfEmpty(); seedBCRGTasksIfEmpty('demo'); + initClickUpSync(ctx.syncServer); }, async onSpaceCreate(ctx: SpaceLifecycleContext) { if (!_syncServer) return; diff --git a/modules/rtasks/schemas.ts b/modules/rtasks/schemas.ts index 9b65128..3766212 100644 --- a/modules/rtasks/schemas.ts +++ b/modules/rtasks/schemas.ts @@ -9,6 +9,16 @@ import type { DocSchema } from '../../shared/local-first/document'; // ── 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 { id: string; spaceId: string; @@ -22,6 +32,17 @@ export interface TaskItem { sortOrder: number; createdAt: 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; // rTasks → ClickUp + reverseStatusMap: Record; // ClickUp → rTasks } export interface BoardMeta { @@ -35,6 +56,7 @@ export interface BoardMeta { labels: string[]; createdAt: number; updatedAt: number; + clickup?: ClickUpBoardMeta; } export interface BoardDoc { @@ -85,6 +107,44 @@ export function boardDocId(space: string, boardId: string) { 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 = { + 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( id: string, spaceId: string, diff --git a/server/index.ts b/server/index.ts index bf6032f..cd012cb 100644 --- a/server/index.ts +++ b/server/index.ts @@ -92,6 +92,7 @@ import { backupRouter } from "./local-first/backup-routes"; import { oauthRouter } from "./oauth/index"; import { setNotionOAuthSyncServer } from "./oauth/notion"; import { setGoogleOAuthSyncServer } from "./oauth/google"; +import { setClickUpOAuthSyncServer } from "./oauth/clickup"; import { notificationRouter } from "./notification-routes"; import { registerUserConnection, unregisterUserConnection, notify } from "./notification-service"; import { SystemClock } from "./clock-service"; @@ -2493,7 +2494,8 @@ for (const mod of getAllModules()) { || pathname.includes("/rcart/pay/") || pathname.includes("/rwallet/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")) { const token = extractToken(c.req.raw.headers); @@ -3630,6 +3632,7 @@ const server = Bun.serve({ // Pass syncServer to OAuth handlers setNotionOAuthSyncServer(syncServer); setGoogleOAuthSyncServer(syncServer); + setClickUpOAuthSyncServer(syncServer); })(); // Ensure generated files directory exists diff --git a/server/oauth/clickup.ts b/server/oauth/clickup.ts new file mode 100644 index 0000000..82ca28d --- /dev/null +++ b/server/oauth/clickup.ts @@ -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(docId); + if (!doc) { + doc = Automerge.change(Automerge.init(), '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(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(docId); + + if (doc?.clickup) { + _syncServer!.changeDoc(docId, 'Disconnect ClickUp', (d) => { + delete d.clickup; + }); + } + + return c.json({ ok: true }); +}); + +export { clickupOAuthRoutes }; diff --git a/server/oauth/index.ts b/server/oauth/index.ts index 09de1eb..5504893 100644 --- a/server/oauth/index.ts +++ b/server/oauth/index.ts @@ -4,6 +4,7 @@ * Provides OAuth2 authorize/callback/disconnect flows for: * - Notion (workspace-level integration) * - Google (user-level, with token refresh) + * - ClickUp (workspace-level, task sync) * * Tokens are stored in Automerge docs per space via SyncServer. */ @@ -11,10 +12,12 @@ import { Hono } from 'hono'; import { notionOAuthRoutes } from './notion'; import { googleOAuthRoutes } from './google'; +import { clickupOAuthRoutes } from './clickup'; const oauthRouter = new Hono(); oauthRouter.route('/notion', notionOAuthRoutes); oauthRouter.route('/google', googleOAuthRoutes); +oauthRouter.route('/clickup', clickupOAuthRoutes); export { oauthRouter }; diff --git a/shared/components/rstack-collab-overlay.ts b/shared/components/rstack-collab-overlay.ts index 620d99d..b86cccd 100644 --- a/shared/components/rstack-collab-overlay.ts +++ b/shared/components/rstack-collab-overlay.ts @@ -696,21 +696,21 @@ const OVERLAY_CSS = ` gap: 4px; padding: 4px 10px 4px 6px; 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); backdrop-filter: blur(8px); font-size: 11px; - color: var(--rs-text-secondary, #ccc); + color: var(--rs-text-secondary, #94a3b8); pointer-events: auto; cursor: pointer; 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; white-space: nowrap; } .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 { @@ -748,7 +748,7 @@ const OVERLAY_CSS = ` max-height: calc(100vh - 120px); background: var(--rs-bg-surface, #1e1e1e); 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; overflow: hidden; pointer-events: auto; @@ -761,7 +761,7 @@ const OVERLAY_CSS = ` .people-panel-header { 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; align-items: center; justify-content: space-between; @@ -770,15 +770,15 @@ const OVERLAY_CSS = ` .people-panel-header h3 { font-size: 14px; - color: var(--rs-text-primary, #fff); + color: var(--rs-text-primary, #0f172a); margin: 0; font-weight: 600; } .panel-count { font-size: 12px; - color: var(--rs-text-secondary, #ccc); - background: var(--rs-bg-surface-raised, rgba(255,255,255,0.06)); + color: var(--rs-text-secondary, #374151); + background: var(--rs-bg-surface-raised, #f0efe9); padding: 2px 8px; border-radius: 10px; } @@ -881,17 +881,17 @@ const OVERLAY_CSS = ` .actions-btn { 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; - background: var(--rs-bg-surface, #1e1e1e); + background: var(--rs-bg-surface-raised, #334155); cursor: pointer; font-size: 12px; - color: var(--rs-text-muted, #888); + color: var(--rs-text-muted, #64748b); transition: background 0.15s; } .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 {