planning-center-api
Version:
A TypeScript toolkit for building applications on top of the Planning Center API.
316 lines (315 loc) • 11.5 kB
JavaScript
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;
}
}
}