rspace-online/modules/rtasks/lib/clickup-client.ts

166 lines
4.3 KiB
TypeScript

/**
* 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}`);
}
}