UNPKG

planning-center-api

Version:

A TypeScript toolkit for building applications on top of the Planning Center API.

316 lines (315 loc) 11.5 kB
import { PeopleApp } from "./apps/people.js"; import { ServicesApp } from "./apps/services.js"; import { GroupsApp } from "./apps/groups.js"; import { CheckInsApp } from "./apps/check-ins.js"; import { HomeApp } from "./apps/home.js"; import { ChatApp } from "./apps/chat.js"; import { RegistrationsApp } from "./apps/registrations.js"; import { CalendarApp } from "./apps/calendar.js"; import { GivingApp } from "./apps/giving.js"; import { ApiApp } from "./apps/api.js"; import { PublishingApp } from "./apps/publishing.js"; import { WebhooksApp } from "./apps/webhooks.js"; export class PlanningCenter { constructor(config = {}) { this.baseUrl = "https://api.planningcenteronline.com"; this.lastRequestTime = 0; this.tokenExpiryMs = 7200000; // 2 hours default this.config = { rateLimitDelay: 100, maxRetries: 3, autoPaginate: true, ...config, }; // Use provided auth or default to ENV variables for basic auth this.auth = config.auth || this.getDefaultAuth(); if (this.auth.type === "bearer") { this.currentTokens = { access: this.auth.bearerToken, refresh: this.auth.refreshToken, }; // Set token refresh time if provided if (this.auth.lastRefreshedAt) { this.tokenRefreshedAt = this.parseTimestamp(this.auth.lastRefreshedAt); } // Set custom expiry time if provided if (this.auth.tokenExpiryMs) { this.tokenExpiryMs = this.auth.tokenExpiryMs; } } } parseTimestamp(timestamp) { if (timestamp instanceof Date) { return timestamp.getTime(); } if (typeof timestamp === "string") { return new Date(timestamp).getTime(); } return timestamp; } getDefaultAuth() { const clientId = process.env.PCO_API_CLIENT_ID; const clientSecret = process.env.PCO_API_SECRET; if (!clientId || !clientSecret) { throw new Error("Authentication required: Either provide auth config or set PCO_API_CLIENT_ID and PCO_API_SECRET environment variables"); } return { type: "basic", clientId, clientSecret, }; } get people() { return new PeopleApp(this); } get services() { return new ServicesApp(this); } get groups() { return new GroupsApp(this); } get checkIns() { return new CheckInsApp(this); } get home() { return new HomeApp(this); } get chat() { return new ChatApp(this); } get registrations() { return new RegistrationsApp(this); } get calendar() { return new CalendarApp(this); } get giving() { return new GivingApp(this); } get api() { return new ApiApp(this); } get publishing() { return new PublishingApp(this); } get webhooks() { return new WebhooksApp(this); } async request(method, path, body, options) { // Check if token needs proactive refresh if (this.shouldProactivelyRefresh()) { await this.refreshAccessToken(); } // Add query parameters if provided let finalPath = path; if (options?.per_page || options?.offset !== undefined) { const url = new URL(finalPath, this.baseUrl); if (options.per_page) { url.searchParams.set("per_page", options.per_page.toString()); } if (options.offset !== undefined) { url.searchParams.set("offset", options.offset.toString()); } finalPath = url.pathname + url.search; } const autoPaginate = options?.autoPaginate !== undefined ? options.autoPaginate : this.config.autoPaginate; // For GET requests with autoPaginate, collect all pages if (method === "GET" && autoPaginate) { return this.requestWithPagination(finalPath); } return this.singleRequest(method, finalPath, body); } async singleRequest(method, path, body) { await this.handleRateLimit(); let retries = 0; const maxRetries = this.config.maxRetries; while (retries <= maxRetries) { try { const headers = await this.getHeaders(); const url = `${this.baseUrl}${path}`; const options = { method, headers, }; if (body) { options.body = JSON.stringify(body); } const response = await fetch(url, options); // Handle rate limiting if (response.status === 429) { retries++; if (retries > maxRetries) { throw new Error("Max retries exceeded for rate limiting"); } const retryAfter = response.headers.get("Retry-After"); const delay = retryAfter ? parseInt(retryAfter) * 1000 : 2000 * retries; await this.sleep(delay); continue; } // Handle token refresh for 401 if (response.status === 401 && this.shouldAutoRefresh()) { const refreshed = await this.refreshAccessToken(); if (refreshed) { retries++; continue; } } if (!response.ok) { const errorText = await response.text(); throw new Error(`API Error (${response.status}): ${errorText}`); } // Handle empty responses (like 204 No Content for DELETE) if (response.status === 204 || response.headers.get("content-length") === "0") { return { data: undefined, }; } const jsonResponse = (await response.json()); return { data: jsonResponse.data, meta: jsonResponse.meta, links: jsonResponse.links, }; } catch (error) { if (retries >= maxRetries) { throw error; } retries++; await this.sleep(1000 * retries); } } throw new Error("Request failed after all retries"); } async requestWithPagination(path) { const allData = []; let nextUrl = path; let lastMeta; let lastLinks; while (nextUrl) { const response = await this.singleRequest("GET", nextUrl); if (Array.isArray(response.data)) { allData.push(...response.data); } else { // Single item response, return as-is return response; } lastMeta = response.meta; lastLinks = response.links; // Check for next page if (response.links?.next) { // Extract path from full URL const url = new URL(response.links.next); nextUrl = url.pathname + url.search; } else { nextUrl = null; } } return { data: allData, meta: lastMeta, links: lastLinks, }; } async getHeaders() { const headers = { "Content-Type": "application/json", }; if (this.auth.type === "basic") { const credentials = Buffer.from(`${this.auth.clientId}:${this.auth.clientSecret}`).toString("base64"); headers["Authorization"] = `Basic ${credentials}`; } else if (this.auth.type === "bearer" && this.currentTokens) { headers["Authorization"] = `Bearer ${this.currentTokens.access}`; } return headers; } async handleRateLimit() { const now = Date.now(); const timeSinceLastRequest = now - this.lastRequestTime; const delay = this.config.rateLimitDelay; if (timeSinceLastRequest < delay) { await this.sleep(delay - timeSinceLastRequest); } this.lastRequestTime = Date.now(); } sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } shouldAutoRefresh() { return (this.auth.type === "bearer" && this.auth.autoRefresh === true && !!this.currentTokens?.refresh); } shouldProactivelyRefresh() { if (!this.shouldAutoRefresh() || !this.tokenRefreshedAt) { return false; } const now = Date.now(); const timeSinceRefresh = now - this.tokenRefreshedAt; // Refresh if token is within 5 minutes of expiring const bufferMs = 5 * 60 * 1000; // 5 minutes return timeSinceRefresh >= this.tokenExpiryMs - bufferMs; } async refreshAccessToken() { if (!this.currentTokens?.refresh) { return false; } // For OAuth token refresh, we need client_id and client_secret if (this.auth.type !== "bearer") { return false; } const clientId = this.auth.clientId || process.env.PCO_CLIENT_ID || process.env.NEXT_PUBLIC_PCO_CLIENT_ID; const clientSecret = this.auth.clientSecret || process.env.PCO_SECRET || process.env.PCO_CLIENT_SECRET; if (!clientId || !clientSecret) { console.error("OAuth token refresh requires clientId and clientSecret"); return false; } try { const params = new URLSearchParams({ grant_type: "refresh_token", client_id: clientId, client_secret: clientSecret, refresh_token: this.currentTokens.refresh, }); const response = await fetch("https://api.planningcenteronline.com/oauth/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", }, body: params.toString(), }); if (!response.ok) { const errorText = await response.text(); console.error("Token refresh failed:", response.status, errorText); return false; } const data = (await response.json()); this.currentTokens = { access: data.access_token, refresh: data.refresh_token || this.currentTokens.refresh, }; this.tokenRefreshedAt = Date.now(); // Call the onTokenRefresh callback if provided if (this.auth.onTokenRefresh) { const tokens = { accessToken: this.currentTokens.access, refreshToken: this.currentTokens.refresh, }; await this.auth.onTokenRefresh(tokens); } return true; } catch (error) { console.error("Token refresh error:", error); return false; } } }