import axios, { AxiosError, AxiosInstance } from "axios";
import { PCO_API_BASE_URL } from "../constants.js";
import type { JsonApiResponse, JsonApiResource } from "../types.js";
let apiClient: AxiosInstance | null = null;
/**
* Initialize the PCO API client using Personal Access Token credentials.
* Credentials are read from PCO_APP_ID and PCO_SECRET environment variables.
*/
export function getApiClient(): AxiosInstance {
if (apiClient) return apiClient;
const appId = process.env.PCO_APP_ID;
const secret = process.env.PCO_SECRET;
if (!appId || !secret) {
throw new Error(
"Missing PCO credentials. Set PCO_APP_ID and PCO_SECRET environment variables. " +
"Get your credentials at https://api.planningcenteronline.com/oauth/applications"
);
}
apiClient = axios.create({
baseURL: PCO_API_BASE_URL,
auth: { username: appId, password: secret },
timeout: 30000,
headers: {
"Content-Type": "application/json",
"Accept": "application/vnd.api+json",
},
});
return apiClient;
}
/**
* Make a GET request to the PCO API.
*/
export async function apiGet<T = JsonApiResource>(
path: string,
params?: Record<string, string | number | boolean | undefined>
): Promise<JsonApiResponse<T>> {
const client = getApiClient();
const response = await client.get<JsonApiResponse<T>>(path, { params });
return response.data;
}
/**
* Make a POST request to the PCO API.
*/
export async function apiPost<T = JsonApiResource>(
path: string,
body: unknown
): Promise<JsonApiResponse<T>> {
const client = getApiClient();
const response = await client.post<JsonApiResponse<T>>(path, body);
return response.data;
}
/**
* Make a PATCH request to the PCO API.
*/
export async function apiPatch<T = JsonApiResource>(
path: string,
body: unknown
): Promise<JsonApiResponse<T>> {
const client = getApiClient();
const response = await client.patch<JsonApiResponse<T>>(path, body);
return response.data;
}
/**
* Make a DELETE request to the PCO API.
*/
export async function apiDelete(path: string): Promise<void> {
const client = getApiClient();
await client.delete(path);
}
/**
* Convert an API error to a human-readable message.
*/
export function handleApiError(error: unknown): string {
if (error instanceof AxiosError) {
if (error.response) {
const status = error.response.status;
const detail = (() => {
try {
const data = error.response.data as Record<string, unknown>;
if (data?.errors && Array.isArray(data.errors)) {
return (data.errors as Array<{detail?: string}>)
.map((e) => e.detail)
.filter(Boolean)
.join("; ");
}
} catch {
// ignore parse errors
}
return null;
})();
switch (status) {
case 400:
return `Error: Bad request${detail ? ` — ${detail}` : ""}. Check your input parameters.`;
case 401:
return "Error: Authentication failed. Check your PCO_APP_ID and PCO_SECRET.";
case 403:
return "Error: Permission denied. You don't have access to this resource.";
case 404:
return "Error: Resource not found. Check that the ID is correct.";
case 422:
return `Error: Validation failed${detail ? ` — ${detail}` : ""}. Check your input.`;
case 429:
return "Error: Rate limit exceeded. Please wait before making more requests.";
case 500:
return "Error: PCO server error. Try again later.";
default:
return `Error: API request failed with status ${status}${detail ? ` — ${detail}` : ""}.`;
}
} else if (error.code === "ECONNABORTED") {
return "Error: Request timed out. Please try again.";
} else if (error.code === "ECONNREFUSED") {
return "Error: Could not connect to Planning Center. Check your internet connection.";
}
}
return `Error: ${error instanceof Error ? error.message : String(error)}`;
}
/**
* Build PCO pagination query parameters (offset-based, JSON API style).
*/
export function buildPaginationParams(
limit: number,
offset: number
): Record<string, number> {
return {
"per_page": limit,
"offset": offset,
};
}
/**
* Extract total count from a JSON API response.
*/
export function getTotalCount(response: JsonApiResponse): number {
return response.meta?.total_count ?? 0;
}
/**
* Ensure response data is always an array.
*/
export function ensureArray<T>(data: T | T[]): T[] {
return Array.isArray(data) ? data : [data];
}