@zackbart/connecta 0.28.0 → 0.28.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +173 -0
- package/README.md +8 -5
- package/dist/artifacts/connector.js +1 -1
- package/dist/auth/downstream-oauth.js +147 -28
- package/dist/auth/static-oauth.js +504 -0
- package/dist/connectors/api-connector.d.ts +168 -0
- package/dist/connectors/api-connector.js +127 -0
- package/dist/connectors/api.d.ts +8 -71
- package/dist/connectors/api.js +10 -99
- package/dist/connectors/guarded-fetch.js +37 -3
- package/dist/connectors/remote-mcp.js +7 -32
- package/dist/index.d.ts +1 -1
- package/dist/oauth-partition.js +17 -0
- package/dist/providers/basecamp.d.ts +65 -0
- package/dist/providers/basecamp.js +445 -0
- package/dist/providers/breeze.d.ts +34 -0
- package/dist/providers/breeze.js +1402 -0
- package/dist/providers/ccb.d.ts +103 -0
- package/dist/providers/ccb.js +1570 -0
- package/dist/providers/cloudflare.js +1 -1
- package/dist/providers/linear.js +1 -1
- package/dist/providers/mixpanel.js +1 -1
- package/dist/providers/notion.js +1 -1
- package/dist/providers/overflow.d.ts +60 -0
- package/dist/providers/overflow.js +1572 -0
- package/dist/providers/planning-center.d.ts +59 -0
- package/dist/providers/planning-center.js +2039 -0
- package/dist/providers/revenuecat.js +1 -1
- package/dist/providers/stripe.js +1 -1
- package/dist/providers/tithely.d.ts +49 -0
- package/dist/providers/tithely.js +1034 -0
- package/dist/providers/vercel.js +1 -1
- package/dist/routes/oauth.js +29 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/architecture.md +5 -3
- package/documentation/auth.md +120 -6
- package/ethos.md +3 -3
- package/package.json +25 -1
- package/templates/node/package.json +1 -1
|
@@ -0,0 +1,2039 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Planning Center Online as a hand-written `api()` provider: named tools for
|
|
3
|
+
* the jobs a church staff agent actually does, plus two hatches split by safety
|
|
4
|
+
* that reach every endpoint of every product.
|
|
5
|
+
*
|
|
6
|
+
* Why not the hosted MCP server. Planning Center operates its own at
|
|
7
|
+
* `https://mcp.planningcenteronline.com/mcp` (GA 2026-08-20). It was considered
|
|
8
|
+
* and passed over for this connection: its writes stop at People, and a proxy
|
|
9
|
+
* owns none of the names, schemas, projections, or errors (P1), so Services
|
|
10
|
+
* scheduling, Groups membership, workflow cards, and the Giving reads would
|
|
11
|
+
* all be out of reach. Full REST coverage is the maintainer's choice; the
|
|
12
|
+
* hosted server remains reachable through `remoteMcp()` for a deployment that
|
|
13
|
+
* wants it.
|
|
14
|
+
*
|
|
15
|
+
* Why no SDK. Planning Center publishes none worth a dependency, and the API is
|
|
16
|
+
* uniform JSON:API 1.0 over HTTP Basic, so Web `fetch` behind the guarded
|
|
17
|
+
* transport is the whole client and the provider stays Workers-clean.
|
|
18
|
+
*
|
|
19
|
+
* Drift. Planning Center publishes an OpenAPI 3.1 document per product and
|
|
20
|
+
* dated version (`/<app>/v2/open_api/<version>`) and a credential-free
|
|
21
|
+
* documentation graph listing every version (`/<app>/v2/documentation`).
|
|
22
|
+
* `scripts/drift/planning-center-endpoints.json` records each endpoint a named
|
|
23
|
+
* tool calls, digested at the version pinned below, and
|
|
24
|
+
* `npm run providers:check -- --provider planning-center` reports a touched
|
|
25
|
+
* contract that moved and a newly published version of any pinned product.
|
|
26
|
+
* Both are evidence only: no tool here is generated from either document.
|
|
27
|
+
*/
|
|
28
|
+
import { apiConnector as api } from "../connectors/api-connector.js";
|
|
29
|
+
import { guardedFetch, retryAfterMs, } from "../connectors/guarded-fetch.js";
|
|
30
|
+
import { ConnectorCallError } from "../errors.js";
|
|
31
|
+
import { CONNECTA_VERSION } from "../version.js";
|
|
32
|
+
/** Planning Center's REST origin. Override only for a proxy or test double. */
|
|
33
|
+
export const PLANNING_CENTER_API_BASE_URL = "https://api.planningcenteronline.com";
|
|
34
|
+
/** The products behind `/<app>/v2`, in the order their guides list them. */
|
|
35
|
+
export const PLANNING_CENTER_APPS = [
|
|
36
|
+
"people",
|
|
37
|
+
"services",
|
|
38
|
+
"groups",
|
|
39
|
+
"check-ins",
|
|
40
|
+
"calendar",
|
|
41
|
+
"registrations",
|
|
42
|
+
"giving",
|
|
43
|
+
"publishing",
|
|
44
|
+
"webhooks",
|
|
45
|
+
"api",
|
|
46
|
+
];
|
|
47
|
+
/**
|
|
48
|
+
* One reviewed `X-PCO-API-Version` per product, sent on every request.
|
|
49
|
+
*
|
|
50
|
+
* The header is optional, and omitting it is the trap: Planning Center then
|
|
51
|
+
* applies a per-application default chosen in a web dashboard, so behavior
|
|
52
|
+
* would follow whoever last clicked there rather than this file. Each date is
|
|
53
|
+
* the newest non-beta version in the product's documentation graph on
|
|
54
|
+
* 2026-10-05, and every projection below is written against it. A breaking
|
|
55
|
+
* change arrives as a new dated version, never inside this one, so moving a
|
|
56
|
+
* pin is a reviewed change to this table, its drift manifest, and whatever
|
|
57
|
+
* projection the version's change list touches. Notes worth keeping:
|
|
58
|
+
*
|
|
59
|
+
* - groups `2023-07-10` removed every person attribute from `Membership`, so a
|
|
60
|
+
* membership is nameless without `include=person`.
|
|
61
|
+
* - calendar `2026-06-22` may return `BookedEvent*` stubs in place of events on
|
|
62
|
+
* booking and conflict traversals for users without access to the event.
|
|
63
|
+
* - webhooks `2022-10-20` renamed `Subscription` to `WebhookSubscription`.
|
|
64
|
+
* - services `2018-11-01` and giving `2019-10-18` are simply the newest there is.
|
|
65
|
+
*
|
|
66
|
+
* The hatches send the same pin for the app their path names and accept a
|
|
67
|
+
* per-call `version` override for an endpoint only a newer version serves.
|
|
68
|
+
*/
|
|
69
|
+
export const PLANNING_CENTER_API_VERSIONS = {
|
|
70
|
+
people: "2026-06-04",
|
|
71
|
+
services: "2018-11-01",
|
|
72
|
+
groups: "2023-07-10",
|
|
73
|
+
"check-ins": "2025-05-28",
|
|
74
|
+
calendar: "2026-06-22",
|
|
75
|
+
registrations: "2025-05-01",
|
|
76
|
+
giving: "2019-10-18",
|
|
77
|
+
publishing: "2024-03-25",
|
|
78
|
+
webhooks: "2022-10-20",
|
|
79
|
+
api: "2026-09-24",
|
|
80
|
+
};
|
|
81
|
+
/** Planning Center's own cap on `per_page`; its default is 25. */
|
|
82
|
+
const MAX_PAGE_SIZE = 100;
|
|
83
|
+
const DEFAULT_PAGE_SIZE = 25;
|
|
84
|
+
/**
|
|
85
|
+
* A ceiling on absurdity, not a budget. `per_page` tops out at 100, and the
|
|
86
|
+
* heaviest projection here — 100 people with emails and phone numbers
|
|
87
|
+
* included — is a few hundred kilobytes.
|
|
88
|
+
*/
|
|
89
|
+
const PLANNING_CENTER_MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
|
|
90
|
+
/**
|
|
91
|
+
* Planning Center documents 100 requests per 20 seconds per authenticated user
|
|
92
|
+
* (429 with `Retry-After` in seconds past it, and `X-PCO-API-Request-Rate-*`
|
|
93
|
+
* headers on every response). The rolling window transcribes that number, as
|
|
94
|
+
* Cloudflare's per-user limit is transcribed: a per-runtime approximation, not
|
|
95
|
+
* an enforcement, because N isolates each keep their own counter and every
|
|
96
|
+
* other integration the token's user runs draws on the same bucket.
|
|
97
|
+
* `maxConcurrency: 5` is connecta's choice, not Planning Center's — the bound
|
|
98
|
+
* that stops one `execute_code` fan-out from spending the window in a tick.
|
|
99
|
+
* Admission meters tool calls, not requests: `get_plan` spends three.
|
|
100
|
+
*/
|
|
101
|
+
const PLANNING_CENTER_ADMISSION = {
|
|
102
|
+
rules: [
|
|
103
|
+
{
|
|
104
|
+
maxConcurrency: 5,
|
|
105
|
+
budget: { kind: "rolling-window", maxCalls: 100, windowMs: 20_000 },
|
|
106
|
+
maxQueueSize: 32,
|
|
107
|
+
queueTimeoutMs: 5_000,
|
|
108
|
+
retryAfterMs: 1_000,
|
|
109
|
+
},
|
|
110
|
+
],
|
|
111
|
+
};
|
|
112
|
+
/** Planning Center refuses a request without a descriptive one (HTTP 403). */
|
|
113
|
+
const DEFAULT_USER_AGENT = `connecta/${CONNECTA_VERSION} (+https://github.com/zackbart/connecta)`;
|
|
114
|
+
const PERSONAL_ACCESS_TOKEN = {
|
|
115
|
+
credential: {
|
|
116
|
+
label: "Planning Center personal access token",
|
|
117
|
+
description: "Create one at api.planningcenteronline.com/oauth/applications → Personal Access Tokens. It acts with the permissions of the user who creates it, in every product that user can open, so create it as a user whose access matches this connection's purpose.",
|
|
118
|
+
fields: [
|
|
119
|
+
{
|
|
120
|
+
name: "applicationId",
|
|
121
|
+
label: "Application ID",
|
|
122
|
+
description: "The personal access token's Application ID.",
|
|
123
|
+
placeholder: "Paste Application ID",
|
|
124
|
+
inputType: "text",
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
name: "secret",
|
|
128
|
+
label: "Secret",
|
|
129
|
+
description: "The personal access token's Secret, shown once when the token is created.",
|
|
130
|
+
placeholder: "Paste Secret",
|
|
131
|
+
inputType: "password",
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
},
|
|
135
|
+
async headers(ctx) {
|
|
136
|
+
const applicationId = (await ctx.credential?.get("applicationId"))?.trim();
|
|
137
|
+
const secret = (await ctx.credential?.get("secret"))?.trim();
|
|
138
|
+
if (!applicationId || !secret) {
|
|
139
|
+
throw new ConnectorCallError("auth_required", "No Planning Center personal access token is configured for this connector; it needs both the Application ID and the Secret. Call authorize_connector for recovery options. When available, an operator can add the token in this connection in the operator UI.");
|
|
140
|
+
}
|
|
141
|
+
// HTTP Basic is `id:secret` in Latin-1 base64. A colon in the id, or any
|
|
142
|
+
// whitespace or non-ASCII character, is a pasting mistake no real token
|
|
143
|
+
// contains — and btoa would either throw or frame it ambiguously.
|
|
144
|
+
if (applicationId.includes(":") || /[^\x21-\x7e]/.test(applicationId + secret)) {
|
|
145
|
+
throw new ConnectorCallError("auth_required", "The configured Planning Center Application ID or Secret contains characters a token never has. An operator must re-paste both fields.");
|
|
146
|
+
}
|
|
147
|
+
return { Authorization: `Basic ${btoa(`${applicationId}:${secret}`)}` };
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
function asRecord(value) {
|
|
151
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
152
|
+
? value
|
|
153
|
+
: {};
|
|
154
|
+
}
|
|
155
|
+
function asArray(value) {
|
|
156
|
+
return Array.isArray(value) ? value : [];
|
|
157
|
+
}
|
|
158
|
+
function compact(value) {
|
|
159
|
+
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
|
|
160
|
+
}
|
|
161
|
+
function isResource(value) {
|
|
162
|
+
const record = asRecord(value);
|
|
163
|
+
return typeof record["id"] === "string" && typeof record["type"] === "string";
|
|
164
|
+
}
|
|
165
|
+
function resources(value) {
|
|
166
|
+
return (Array.isArray(value) ? value : value ? [value] : []).filter(isResource);
|
|
167
|
+
}
|
|
168
|
+
function indexIncluded(document) {
|
|
169
|
+
const index = new Map();
|
|
170
|
+
for (const resource of resources(document["included"])) {
|
|
171
|
+
index.set(`${resource.type}:${resource.id}`, resource);
|
|
172
|
+
}
|
|
173
|
+
return index;
|
|
174
|
+
}
|
|
175
|
+
function relationshipData(resource, name) {
|
|
176
|
+
return resource.relationships?.[name]?.data;
|
|
177
|
+
}
|
|
178
|
+
/** The related id, `null` when Planning Center says there is none. */
|
|
179
|
+
function relId(resource, name) {
|
|
180
|
+
const data = relationshipData(resource, name);
|
|
181
|
+
if (data === null)
|
|
182
|
+
return null;
|
|
183
|
+
const id = asRecord(data)["id"];
|
|
184
|
+
return typeof id === "string" ? id : undefined;
|
|
185
|
+
}
|
|
186
|
+
function related(resource, name, index) {
|
|
187
|
+
const data = asRecord(relationshipData(resource, name));
|
|
188
|
+
return typeof data["type"] === "string" && typeof data["id"] === "string"
|
|
189
|
+
? index.get(`${data["type"]}:${data["id"]}`)
|
|
190
|
+
: undefined;
|
|
191
|
+
}
|
|
192
|
+
function relatedMany(resource, name, index) {
|
|
193
|
+
return asArray(relationshipData(resource, name))
|
|
194
|
+
.map((entry) => {
|
|
195
|
+
const ref = asRecord(entry);
|
|
196
|
+
return index.get(`${String(ref["type"])}:${String(ref["id"])}`);
|
|
197
|
+
})
|
|
198
|
+
.filter((item) => item !== undefined);
|
|
199
|
+
}
|
|
200
|
+
/** `{ id, ...the named attributes }`, keeping Planning Center's own names. */
|
|
201
|
+
function pick(resource, names) {
|
|
202
|
+
const attributes = resource.attributes ?? {};
|
|
203
|
+
const out = { id: resource.id };
|
|
204
|
+
for (const name of names) {
|
|
205
|
+
if (attributes[name] !== undefined)
|
|
206
|
+
out[name] = attributes[name];
|
|
207
|
+
}
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
function attribute(resource, name) {
|
|
211
|
+
return resource?.attributes?.[name];
|
|
212
|
+
}
|
|
213
|
+
/** A JSON:API write document. `undefined` drops a key; `null` clears a field. */
|
|
214
|
+
function writeDocument(type, attributes) {
|
|
215
|
+
return { data: { type, attributes: compact(attributes) } };
|
|
216
|
+
}
|
|
217
|
+
/** One branchable signal: `meta.next.offset`, Planning Center's own cursor. */
|
|
218
|
+
function pageOf(document) {
|
|
219
|
+
const meta = asRecord(document["meta"]);
|
|
220
|
+
const next = asRecord(meta["next"])["offset"];
|
|
221
|
+
const nextOffset = typeof next === "number" && Number.isInteger(next) ? next : null;
|
|
222
|
+
const total = meta["total_count"];
|
|
223
|
+
return {
|
|
224
|
+
hasMore: nextOffset !== null,
|
|
225
|
+
nextOffset,
|
|
226
|
+
totalCount: typeof total === "number" ? total : null,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
const APP_PATH = /^\/([a-z-]+)\/v2(?:\/|$)/;
|
|
230
|
+
function isApp(value) {
|
|
231
|
+
return PLANNING_CENTER_APPS.includes(value);
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* The product a path addresses, read after URL normalization — the same
|
|
235
|
+
* normalization the guarded transport applies — so `..` cannot carry a request
|
|
236
|
+
* from `/people/v2` to `/oauth` or anywhere else outside a product.
|
|
237
|
+
*/
|
|
238
|
+
function appForPath(path) {
|
|
239
|
+
let normalized;
|
|
240
|
+
if (path.startsWith("/") && !path.startsWith("//")) {
|
|
241
|
+
try {
|
|
242
|
+
normalized = new URL(`https://planning-center.invalid${path}`).pathname;
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
normalized = undefined;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
const app = normalized === undefined ? undefined : APP_PATH.exec(normalized)?.[1];
|
|
249
|
+
if (!app || !isApp(app)) {
|
|
250
|
+
throw new ConnectorCallError("invalid_args", `A Planning Center path begins with /<app>/v2, where app is one of ${PLANNING_CENTER_APPS.join(", ")} — for example /people/v2/people. No host, query string, or traversal.`);
|
|
251
|
+
}
|
|
252
|
+
return app;
|
|
253
|
+
}
|
|
254
|
+
function errorDetail(payload, status) {
|
|
255
|
+
const parts = [];
|
|
256
|
+
for (const entry of asArray(asRecord(payload)["errors"])) {
|
|
257
|
+
const error = asRecord(entry);
|
|
258
|
+
const title = typeof error["title"] === "string" ? error["title"].trim() : "";
|
|
259
|
+
const detail = typeof error["detail"] === "string" ? error["detail"].trim() : "";
|
|
260
|
+
const source = asRecord(error["source"]);
|
|
261
|
+
const at = typeof source["pointer"] === "string"
|
|
262
|
+
? ` (${source["pointer"]})`
|
|
263
|
+
: typeof source["parameter"] === "string"
|
|
264
|
+
? ` (${source["parameter"]})`
|
|
265
|
+
: "";
|
|
266
|
+
const text = [title, detail].filter(Boolean).join(": ");
|
|
267
|
+
if (text)
|
|
268
|
+
parts.push(`${text}${at}`);
|
|
269
|
+
}
|
|
270
|
+
return parts.length > 0
|
|
271
|
+
? `Planning Center: ${parts.join("; ")}`
|
|
272
|
+
: `Planning Center returned HTTP ${status}.`;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Map by the caller's next move.
|
|
276
|
+
*
|
|
277
|
+
* - **401** is the token: revoked, mistyped, or the id and secret of two
|
|
278
|
+
* different tokens. Only an operator can fix it.
|
|
279
|
+
* - **403** is the token's *user*, not the token: Planning Center permissions
|
|
280
|
+
* belong to whoever created it, per product and per record. Neither
|
|
281
|
+
* `authorize_connector` nor a retry helps, so it is a non-retryable failure
|
|
282
|
+
* that says so.
|
|
283
|
+
* - **404** is absence — Planning Center answers a permission gap with 403 —
|
|
284
|
+
* but a person id that used to work was most often merged into another.
|
|
285
|
+
*/
|
|
286
|
+
function planningCenterFailure(status, headers, payload) {
|
|
287
|
+
const detail = errorDetail(payload, status);
|
|
288
|
+
if (status === 429) {
|
|
289
|
+
const wait = retryAfterMs(headers);
|
|
290
|
+
return new ConnectorCallError("rate_limited", `${detail} Planning Center allows 100 requests per 20 seconds per user, shared by every integration that user's tokens run.`, wait === undefined ? {} : { retryAfterMs: wait });
|
|
291
|
+
}
|
|
292
|
+
if (status === 401) {
|
|
293
|
+
return new ConnectorCallError("auth_required", `${detail} The personal access token was rejected: revoked, mistyped, or an Application ID and Secret from different tokens. An operator must replace it.`);
|
|
294
|
+
}
|
|
295
|
+
if (status === 403) {
|
|
296
|
+
return new ConnectorCallError("connector_call_failed", `${detail} The token's Planning Center user lacks permission for this product or record. Permissions belong to that user; an operator must raise them or use another user's token. Retrying will not help.`, { retryable: false });
|
|
297
|
+
}
|
|
298
|
+
if (status === 404) {
|
|
299
|
+
return new ConnectorCallError("not_found", `${detail} Confirm the id with its list tool. A person id that used to resolve was probably merged: look it up in /people/v2/person_mergers with where[person_to_remove_id].`);
|
|
300
|
+
}
|
|
301
|
+
if (status === 400 || status === 409 || status === 422) {
|
|
302
|
+
return new ConnectorCallError("invalid_args", detail);
|
|
303
|
+
}
|
|
304
|
+
if (status >= 500) {
|
|
305
|
+
const wait = retryAfterMs(headers);
|
|
306
|
+
return new ConnectorCallError("unavailable", `${detail} Planning Center is failing upstream.`, wait === undefined ? {} : { retryAfterMs: wait });
|
|
307
|
+
}
|
|
308
|
+
return new ConnectorCallError("connector_call_failed", detail, {
|
|
309
|
+
retryable: false,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
function planningCenterCall(send, versions) {
|
|
313
|
+
return async (request, ctx) => {
|
|
314
|
+
const app = appForPath(request.path);
|
|
315
|
+
return await send(compact({
|
|
316
|
+
method: request.method,
|
|
317
|
+
path: request.path,
|
|
318
|
+
query: request.query,
|
|
319
|
+
body: request.body,
|
|
320
|
+
headers: { "X-PCO-API-Version": request.version ?? versions[app] },
|
|
321
|
+
}), ctx, async (response) => {
|
|
322
|
+
const parsed = await response.jsonResult();
|
|
323
|
+
if (!response.ok) {
|
|
324
|
+
throw planningCenterFailure(response.status, response.headers, "value" in parsed ? parsed.value : undefined);
|
|
325
|
+
}
|
|
326
|
+
if ("parseError" in parsed) {
|
|
327
|
+
throw new ConnectorCallError("connector_call_failed", "Planning Center returned a successful response that is not JSON.", { retryable: false });
|
|
328
|
+
}
|
|
329
|
+
return parsed.value;
|
|
330
|
+
});
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
// ---------------------------------------------------------------------------
|
|
334
|
+
// Schemas
|
|
335
|
+
// ---------------------------------------------------------------------------
|
|
336
|
+
const STRING = { type: "string" };
|
|
337
|
+
const NULLABLE_STRING = { type: ["string", "null"] };
|
|
338
|
+
const INTEGER = { type: "integer" };
|
|
339
|
+
const NULLABLE_INTEGER = { type: ["integer", "null"] };
|
|
340
|
+
const BOOLEAN = { type: "boolean" };
|
|
341
|
+
const ARRAY = { type: "array" };
|
|
342
|
+
const OBJECT = { type: "object" };
|
|
343
|
+
function record(properties, required = ["id"]) {
|
|
344
|
+
return { type: "object", properties, required };
|
|
345
|
+
}
|
|
346
|
+
function input(properties, required = []) {
|
|
347
|
+
return { type: "object", properties, required, additionalProperties: false };
|
|
348
|
+
}
|
|
349
|
+
function idProperty(description) {
|
|
350
|
+
return { type: "string", pattern: "^[0-9]+$", description };
|
|
351
|
+
}
|
|
352
|
+
const RAW_PROPERTY = {
|
|
353
|
+
type: "boolean",
|
|
354
|
+
description: "Return Planning Center's untouched JSON:API resources (and `included`) instead of the lean projection.",
|
|
355
|
+
};
|
|
356
|
+
const OFFSET_PROPERTY = {
|
|
357
|
+
type: "integer",
|
|
358
|
+
minimum: 0,
|
|
359
|
+
description: "Rows to skip. Pass page.nextOffset back unchanged; omit for the first page.",
|
|
360
|
+
};
|
|
361
|
+
const DATE_PROPERTY_PATTERN = "^\\d{4}-\\d{2}-\\d{2}(T[0-9:.]+(Z|[+-]\\d{2}:?\\d{2})?)?$";
|
|
362
|
+
function dateProperty(description) {
|
|
363
|
+
return {
|
|
364
|
+
type: "string",
|
|
365
|
+
pattern: DATE_PROPERTY_PATTERN,
|
|
366
|
+
description: `${description} ISO 8601 date or date-time; a bare date is the organization's local day.`,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
function perPageProperty(defaultPageSize) {
|
|
370
|
+
return {
|
|
371
|
+
type: "integer",
|
|
372
|
+
minimum: 1,
|
|
373
|
+
maximum: MAX_PAGE_SIZE,
|
|
374
|
+
description: `Rows per page, 1 to Planning Center's maximum of ${MAX_PAGE_SIZE}. Defaults to this connector's ${defaultPageSize}.`,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
function orderProperty(values) {
|
|
378
|
+
return {
|
|
379
|
+
type: "string",
|
|
380
|
+
enum: values.flatMap((value) => [value, `-${value}`]),
|
|
381
|
+
description: "Sort attribute; a leading '-' reverses it.",
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
const PAGE_SCHEMA = {
|
|
385
|
+
type: "object",
|
|
386
|
+
properties: {
|
|
387
|
+
hasMore: BOOLEAN,
|
|
388
|
+
nextOffset: { type: ["integer", "null"], description: "Pass back as offset while hasMore is true." },
|
|
389
|
+
totalCount: NULLABLE_INTEGER,
|
|
390
|
+
},
|
|
391
|
+
required: ["hasMore", "nextOffset"],
|
|
392
|
+
};
|
|
393
|
+
function listOutput(key, item) {
|
|
394
|
+
return {
|
|
395
|
+
type: "object",
|
|
396
|
+
properties: {
|
|
397
|
+
[key]: { type: "array", items: item },
|
|
398
|
+
page: PAGE_SCHEMA,
|
|
399
|
+
included: { type: "array", description: "Sideloaded resources, only with raw: true." },
|
|
400
|
+
},
|
|
401
|
+
required: [key, "page"],
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
const QUERY_PROPERTY = {
|
|
405
|
+
type: "array",
|
|
406
|
+
description: "Query parameters as name/value pairs, e.g. where[status]=active, include=emails, order=-created_at, per_page, offset.",
|
|
407
|
+
items: {
|
|
408
|
+
type: "object",
|
|
409
|
+
properties: {
|
|
410
|
+
name: { type: "string", minLength: 1, description: "Parameter name, brackets literal: where[first_name]." },
|
|
411
|
+
value: {
|
|
412
|
+
type: ["string", "number", "boolean"],
|
|
413
|
+
description: "Parameter value; the transport encodes it once.",
|
|
414
|
+
},
|
|
415
|
+
},
|
|
416
|
+
required: ["name", "value"],
|
|
417
|
+
additionalProperties: false,
|
|
418
|
+
},
|
|
419
|
+
};
|
|
420
|
+
const HATCH_PATH_PROPERTY = {
|
|
421
|
+
type: "string",
|
|
422
|
+
minLength: 1,
|
|
423
|
+
description: "Path beginning /<app>/v2, as Planning Center documents it: /people/v2/people/1/emails. No host or query string.",
|
|
424
|
+
};
|
|
425
|
+
const VERSION_PROPERTY = {
|
|
426
|
+
type: "string",
|
|
427
|
+
pattern: "^\\d{4}-\\d{2}-\\d{2}$",
|
|
428
|
+
description: "X-PCO-API-Version override. Omit to send this connector's reviewed pin for the path's app.",
|
|
429
|
+
};
|
|
430
|
+
function queryPairs(value) {
|
|
431
|
+
const query = {};
|
|
432
|
+
for (const row of asArray(value)) {
|
|
433
|
+
const pair = asRecord(row);
|
|
434
|
+
if (typeof pair["name"] !== "string")
|
|
435
|
+
continue;
|
|
436
|
+
const item = pair["value"];
|
|
437
|
+
if (typeof item === "string" || typeof item === "number" || typeof item === "boolean") {
|
|
438
|
+
query[pair["name"]] = item;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
return query;
|
|
442
|
+
}
|
|
443
|
+
// --- Output records ---------------------------------------------------------
|
|
444
|
+
const PERSON_SUMMARY = {
|
|
445
|
+
id: STRING,
|
|
446
|
+
name: STRING,
|
|
447
|
+
first_name: STRING,
|
|
448
|
+
last_name: STRING,
|
|
449
|
+
nickname: NULLABLE_STRING,
|
|
450
|
+
status: STRING,
|
|
451
|
+
membership: NULLABLE_STRING,
|
|
452
|
+
child: BOOLEAN,
|
|
453
|
+
primary_email: NULLABLE_STRING,
|
|
454
|
+
primary_phone: NULLABLE_STRING,
|
|
455
|
+
updated_at: STRING,
|
|
456
|
+
};
|
|
457
|
+
const PERSON_SUMMARY_SCHEMA = record(PERSON_SUMMARY);
|
|
458
|
+
const PERSON_SCHEMA = record({
|
|
459
|
+
...PERSON_SUMMARY,
|
|
460
|
+
given_name: NULLABLE_STRING,
|
|
461
|
+
middle_name: NULLABLE_STRING,
|
|
462
|
+
birthdate: NULLABLE_STRING,
|
|
463
|
+
anniversary: NULLABLE_STRING,
|
|
464
|
+
gender: NULLABLE_STRING,
|
|
465
|
+
grade: NULLABLE_INTEGER,
|
|
466
|
+
inactivated_at: NULLABLE_STRING,
|
|
467
|
+
created_at: STRING,
|
|
468
|
+
primary_campus: OBJECT,
|
|
469
|
+
emails: ARRAY,
|
|
470
|
+
phone_numbers: ARRAY,
|
|
471
|
+
addresses: ARRAY,
|
|
472
|
+
households: ARRAY,
|
|
473
|
+
});
|
|
474
|
+
const EMAIL_SCHEMA = record({ id: STRING, address: STRING, location: NULLABLE_STRING, primary: BOOLEAN, person_id: STRING });
|
|
475
|
+
const PHONE_SCHEMA = record({ id: STRING, number: STRING, location: NULLABLE_STRING, primary: BOOLEAN, person_id: STRING });
|
|
476
|
+
const CARD_SCHEMA = record({
|
|
477
|
+
id: STRING,
|
|
478
|
+
stage: STRING,
|
|
479
|
+
person_id: NULLABLE_STRING,
|
|
480
|
+
person_name: NULLABLE_STRING,
|
|
481
|
+
assignee_id: NULLABLE_STRING,
|
|
482
|
+
current_step_id: NULLABLE_STRING,
|
|
483
|
+
current_step_name: NULLABLE_STRING,
|
|
484
|
+
moved_to_step_at: NULLABLE_STRING,
|
|
485
|
+
snooze_until: NULLABLE_STRING,
|
|
486
|
+
overdue: BOOLEAN,
|
|
487
|
+
completed_at: NULLABLE_STRING,
|
|
488
|
+
removed_at: NULLABLE_STRING,
|
|
489
|
+
});
|
|
490
|
+
const NOTE_SCHEMA = record({
|
|
491
|
+
id: STRING,
|
|
492
|
+
note: STRING,
|
|
493
|
+
note_category_id: STRING,
|
|
494
|
+
category: NULLABLE_STRING,
|
|
495
|
+
person_id: STRING,
|
|
496
|
+
display_date: NULLABLE_STRING,
|
|
497
|
+
created_at: STRING,
|
|
498
|
+
created_by_id: NULLABLE_STRING,
|
|
499
|
+
});
|
|
500
|
+
const PLAN_PERSON_SCHEMA = record({
|
|
501
|
+
id: STRING,
|
|
502
|
+
name: STRING,
|
|
503
|
+
status: STRING,
|
|
504
|
+
team_position_name: NULLABLE_STRING,
|
|
505
|
+
team_id: NULLABLE_STRING,
|
|
506
|
+
team_name: NULLABLE_STRING,
|
|
507
|
+
person_id: NULLABLE_STRING,
|
|
508
|
+
decline_reason: NULLABLE_STRING,
|
|
509
|
+
});
|
|
510
|
+
const ITEM_SCHEMA = record({
|
|
511
|
+
id: STRING,
|
|
512
|
+
sequence: INTEGER,
|
|
513
|
+
item_type: STRING,
|
|
514
|
+
title: STRING,
|
|
515
|
+
length: NULLABLE_INTEGER,
|
|
516
|
+
key_name: NULLABLE_STRING,
|
|
517
|
+
service_position: NULLABLE_STRING,
|
|
518
|
+
song_id: NULLABLE_STRING,
|
|
519
|
+
arrangement_id: NULLABLE_STRING,
|
|
520
|
+
});
|
|
521
|
+
const MEMBERSHIP_SCHEMA = record({
|
|
522
|
+
id: STRING,
|
|
523
|
+
role: STRING,
|
|
524
|
+
joined_at: NULLABLE_STRING,
|
|
525
|
+
person_id: STRING,
|
|
526
|
+
first_name: STRING,
|
|
527
|
+
last_name: STRING,
|
|
528
|
+
});
|
|
529
|
+
const DESIGNATIONS = {
|
|
530
|
+
type: "array",
|
|
531
|
+
items: record({ fund_id: NULLABLE_STRING, fund: NULLABLE_STRING, amount_cents: INTEGER }, ["amount_cents"]),
|
|
532
|
+
};
|
|
533
|
+
const DONATION_SCHEMA = record({
|
|
534
|
+
id: STRING,
|
|
535
|
+
amount_cents: INTEGER,
|
|
536
|
+
amount_currency: STRING,
|
|
537
|
+
fee_cents: NULLABLE_INTEGER,
|
|
538
|
+
received_at: NULLABLE_STRING,
|
|
539
|
+
payment_method: NULLABLE_STRING,
|
|
540
|
+
payment_status: NULLABLE_STRING,
|
|
541
|
+
refunded: BOOLEAN,
|
|
542
|
+
person_id: NULLABLE_STRING,
|
|
543
|
+
batch_id: NULLABLE_STRING,
|
|
544
|
+
campus_id: NULLABLE_STRING,
|
|
545
|
+
designations: DESIGNATIONS,
|
|
546
|
+
});
|
|
547
|
+
// --- Projections ------------------------------------------------------------
|
|
548
|
+
function primaryOf(rows, key) {
|
|
549
|
+
const primary = rows.find((row) => attribute(row, "primary") === true) ?? rows[0];
|
|
550
|
+
const value = attribute(primary, key);
|
|
551
|
+
return typeof value === "string" ? value : null;
|
|
552
|
+
}
|
|
553
|
+
function personSummary(person, index) {
|
|
554
|
+
return {
|
|
555
|
+
...pick(person, ["name", "first_name", "last_name", "nickname", "status", "membership", "child"]),
|
|
556
|
+
primary_email: primaryOf(relatedMany(person, "emails", index), "address"),
|
|
557
|
+
primary_phone: primaryOf(relatedMany(person, "phone_numbers", index), "number"),
|
|
558
|
+
...pick(person, ["updated_at"]),
|
|
559
|
+
id: person.id,
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
function emailRecord(email) {
|
|
563
|
+
return compact({ ...pick(email, ["address", "location", "primary"]), person_id: relId(email, "person") ?? undefined });
|
|
564
|
+
}
|
|
565
|
+
function phoneRecord(phone) {
|
|
566
|
+
return compact({ ...pick(phone, ["number", "location", "primary"]), person_id: relId(phone, "person") ?? undefined });
|
|
567
|
+
}
|
|
568
|
+
function personRecord(person, index) {
|
|
569
|
+
const campus = related(person, "primary_campus", index);
|
|
570
|
+
return compact({
|
|
571
|
+
...personSummary(person, index),
|
|
572
|
+
...pick(person, [
|
|
573
|
+
"given_name",
|
|
574
|
+
"middle_name",
|
|
575
|
+
"birthdate",
|
|
576
|
+
"anniversary",
|
|
577
|
+
"gender",
|
|
578
|
+
"grade",
|
|
579
|
+
"inactivated_at",
|
|
580
|
+
"created_at",
|
|
581
|
+
]),
|
|
582
|
+
id: person.id,
|
|
583
|
+
primary_campus: campus
|
|
584
|
+
? { id: campus.id, name: attribute(campus, "name") }
|
|
585
|
+
: relId(person, "primary_campus")
|
|
586
|
+
? { id: relId(person, "primary_campus") }
|
|
587
|
+
: undefined,
|
|
588
|
+
emails: relatedMany(person, "emails", index).map((email) => pick(email, ["address", "location", "primary"])),
|
|
589
|
+
phone_numbers: relatedMany(person, "phone_numbers", index).map((phone) => pick(phone, ["number", "location", "primary"])),
|
|
590
|
+
addresses: relatedMany(person, "addresses", index).map((address) => pick(address, ["street_line_1", "street_line_2", "city", "state", "zip", "country_code", "location", "primary"])),
|
|
591
|
+
households: relatedMany(person, "households", index).map((household) => pick(household, ["name", "primary_contact_id", "member_count"])),
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
function cardRecord(card, index) {
|
|
595
|
+
const person = related(card, "person", index);
|
|
596
|
+
const step = related(card, "current_step", index);
|
|
597
|
+
return {
|
|
598
|
+
...pick(card, ["stage"]),
|
|
599
|
+
person_id: relId(card, "person") ?? null,
|
|
600
|
+
person_name: attribute(person, "name") ?? null,
|
|
601
|
+
assignee_id: relId(card, "assignee") ?? null,
|
|
602
|
+
current_step_id: relId(card, "current_step") ?? null,
|
|
603
|
+
current_step_name: attribute(step, "name") ?? null,
|
|
604
|
+
...pick(card, ["moved_to_step_at", "snooze_until", "overdue", "completed_at", "removed_at"]),
|
|
605
|
+
id: card.id,
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
function noteRecord(note, index) {
|
|
609
|
+
// `include=category` sideloads what the resource itself calls
|
|
610
|
+
// `note_category`; the flat `note_category_id` settles it either way.
|
|
611
|
+
const category = related(note, "category", index) ??
|
|
612
|
+
related(note, "note_category", index) ??
|
|
613
|
+
index.get(`NoteCategory:${String(attribute(note, "note_category_id"))}`);
|
|
614
|
+
return {
|
|
615
|
+
...pick(note, ["note", "note_category_id", "person_id", "display_date", "created_at", "created_by_id"]),
|
|
616
|
+
category: attribute(category, "name") ?? null,
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
function planPersonRecord(member, index) {
|
|
620
|
+
const team = related(member, "team", index);
|
|
621
|
+
return {
|
|
622
|
+
...pick(member, ["name", "status", "team_position_name", "decline_reason"]),
|
|
623
|
+
team_id: relId(member, "team") ?? null,
|
|
624
|
+
team_name: attribute(team, "name") ?? null,
|
|
625
|
+
person_id: relId(member, "person") ?? null,
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
function itemRecord(item) {
|
|
629
|
+
return {
|
|
630
|
+
...pick(item, ["sequence", "item_type", "title", "length", "key_name", "service_position"]),
|
|
631
|
+
song_id: relId(item, "song") ?? null,
|
|
632
|
+
arrangement_id: relId(item, "arrangement") ?? null,
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
function membershipRecord(membership, index) {
|
|
636
|
+
const person = related(membership, "person", index);
|
|
637
|
+
return {
|
|
638
|
+
...pick(membership, ["role", "joined_at"]),
|
|
639
|
+
person_id: relId(membership, "person") ?? null,
|
|
640
|
+
first_name: attribute(person, "first_name") ?? null,
|
|
641
|
+
last_name: attribute(person, "last_name") ?? null,
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
function designations(owner, index) {
|
|
645
|
+
return relatedMany(owner, "designations", index).map((designation) => {
|
|
646
|
+
const fund = related(designation, "fund", index);
|
|
647
|
+
return {
|
|
648
|
+
fund_id: relId(designation, "fund") ?? null,
|
|
649
|
+
fund: attribute(fund, "name") ?? null,
|
|
650
|
+
amount_cents: attribute(designation, "amount_cents"),
|
|
651
|
+
};
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
function donationRecord(donation, index) {
|
|
655
|
+
return {
|
|
656
|
+
...pick(donation, [
|
|
657
|
+
"amount_cents",
|
|
658
|
+
"amount_currency",
|
|
659
|
+
"fee_cents",
|
|
660
|
+
"received_at",
|
|
661
|
+
"payment_method",
|
|
662
|
+
"payment_status",
|
|
663
|
+
"refunded",
|
|
664
|
+
]),
|
|
665
|
+
person_id: relId(donation, "person") ?? null,
|
|
666
|
+
batch_id: relId(donation, "batch") ?? null,
|
|
667
|
+
campus_id: relId(donation, "campus") ?? null,
|
|
668
|
+
designations: designations(donation, index),
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
// ---------------------------------------------------------------------------
|
|
672
|
+
// Tools
|
|
673
|
+
// ---------------------------------------------------------------------------
|
|
674
|
+
const READ = { readOnlyHint: true };
|
|
675
|
+
/** Creates a record and changes nothing that existed: write-routed, not destructive. */
|
|
676
|
+
const ADDITIVE = { readOnlyHint: false, destructiveHint: false };
|
|
677
|
+
const DESTRUCTIVE = { readOnlyHint: false, destructiveHint: true };
|
|
678
|
+
/**
|
|
679
|
+
* A hand-written list tool. The spec is authored per tool; the helper only
|
|
680
|
+
* owns the parts every list shares — paging, `raw`, and the envelope.
|
|
681
|
+
*/
|
|
682
|
+
function listTool(call, defaultPageSize, spec) {
|
|
683
|
+
return {
|
|
684
|
+
name: spec.name,
|
|
685
|
+
description: spec.description,
|
|
686
|
+
annotations: READ,
|
|
687
|
+
inputSchema: input({
|
|
688
|
+
...spec.properties,
|
|
689
|
+
perPage: perPageProperty(defaultPageSize),
|
|
690
|
+
offset: OFFSET_PROPERTY,
|
|
691
|
+
raw: RAW_PROPERTY,
|
|
692
|
+
}, spec.required),
|
|
693
|
+
outputSchema: listOutput(spec.key, spec.item),
|
|
694
|
+
handler: async (args, ctx) => {
|
|
695
|
+
const document = asRecord(await call({
|
|
696
|
+
method: "GET",
|
|
697
|
+
path: spec.path(args),
|
|
698
|
+
query: {
|
|
699
|
+
...spec.query?.(args),
|
|
700
|
+
include: spec.include,
|
|
701
|
+
per_page: args["perPage"] ?? defaultPageSize,
|
|
702
|
+
offset: args["offset"],
|
|
703
|
+
},
|
|
704
|
+
}, ctx));
|
|
705
|
+
const rows = resources(document["data"]);
|
|
706
|
+
if (args["raw"] === true) {
|
|
707
|
+
return { [spec.key]: rows, included: asArray(document["included"]), page: pageOf(document) };
|
|
708
|
+
}
|
|
709
|
+
const index = indexIncluded(document);
|
|
710
|
+
return { [spec.key]: rows.map((row) => spec.project(row, index)), page: pageOf(document) };
|
|
711
|
+
},
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
function segment(value) {
|
|
715
|
+
return encodeURIComponent(String(value));
|
|
716
|
+
}
|
|
717
|
+
/** The single resource a response carries, or a refusal to invent one. */
|
|
718
|
+
function created(document, what) {
|
|
719
|
+
const resource = resources(asRecord(document)["data"])[0];
|
|
720
|
+
if (!resource) {
|
|
721
|
+
throw new ConnectorCallError("connector_call_failed", `Planning Center answered without the ${what} it should return.`, { retryable: false });
|
|
722
|
+
}
|
|
723
|
+
return resource;
|
|
724
|
+
}
|
|
725
|
+
/** `where[a][b][gte]` and `[lt]` for a half-open range on a (nested) attribute. */
|
|
726
|
+
function range(attribute, after, before) {
|
|
727
|
+
const key = `where[${attribute.join("][")}]`;
|
|
728
|
+
return {
|
|
729
|
+
[`${key}[gte]`]: typeof after === "string" ? after : undefined,
|
|
730
|
+
[`${key}[lt]`]: typeof before === "string" ? before : undefined,
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
const PERSON_ID = idProperty("Person id from search_people. The same id resolves in every Planning Center product.");
|
|
734
|
+
const SERVICE_TYPE_ID = idProperty("Service type id from list_service_types.");
|
|
735
|
+
const PLAN_ID = idProperty("Plan id from list_plans.");
|
|
736
|
+
const PERSON_WRITE_PROPERTIES = {
|
|
737
|
+
firstName: { type: "string", minLength: 1, description: "First name." },
|
|
738
|
+
lastName: { type: "string", minLength: 1, description: "Last name." },
|
|
739
|
+
nickname: { type: ["string", "null"], description: "Nickname; null clears it." },
|
|
740
|
+
middleName: { type: ["string", "null"], description: "Middle name; null clears it." },
|
|
741
|
+
birthdate: { type: ["string", "null"], pattern: "^\\d{4}-\\d{2}-\\d{2}$", description: "Birthdate, YYYY-MM-DD; null clears it." },
|
|
742
|
+
anniversary: { type: ["string", "null"], pattern: "^\\d{4}-\\d{2}-\\d{2}$", description: "Anniversary, YYYY-MM-DD; null clears it." },
|
|
743
|
+
gender: { type: ["string", "null"], description: "Gender as the organization records it, e.g. Male or Female." },
|
|
744
|
+
membership: { type: ["string", "null"], description: "Membership type, one of the organization's own values such as Member or Visitor." },
|
|
745
|
+
child: { type: "boolean", description: "Whether the person is a child." },
|
|
746
|
+
primaryCampusId: { type: "string", pattern: "^[0-9]+$", description: "Campus id from /people/v2/campuses." },
|
|
747
|
+
};
|
|
748
|
+
function personAttributes(args) {
|
|
749
|
+
return {
|
|
750
|
+
first_name: args["firstName"],
|
|
751
|
+
last_name: args["lastName"],
|
|
752
|
+
nickname: args["nickname"],
|
|
753
|
+
middle_name: args["middleName"],
|
|
754
|
+
birthdate: args["birthdate"],
|
|
755
|
+
anniversary: args["anniversary"],
|
|
756
|
+
gender: args["gender"],
|
|
757
|
+
membership: args["membership"],
|
|
758
|
+
child: args["child"],
|
|
759
|
+
primary_campus_id: args["primaryCampusId"],
|
|
760
|
+
status: args["status"],
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
const WORKFLOW_CARD_ACTIONS = [
|
|
764
|
+
"promote",
|
|
765
|
+
"go_back",
|
|
766
|
+
"skip_step",
|
|
767
|
+
"snooze",
|
|
768
|
+
"unsnooze",
|
|
769
|
+
"remove",
|
|
770
|
+
"restore",
|
|
771
|
+
];
|
|
772
|
+
function tools(call, defaultPageSize) {
|
|
773
|
+
const perPage = (args) => args["perPage"] ?? defaultPageSize;
|
|
774
|
+
const list = (spec) => listTool(call, defaultPageSize, spec);
|
|
775
|
+
return [
|
|
776
|
+
// --- Hatches ------------------------------------------------------------
|
|
777
|
+
{
|
|
778
|
+
name: "pco_api_get",
|
|
779
|
+
description: "Call any Planning Center GET endpoint in any product and return its untouched JSON:API body. Prefer a named read; this one does not flatten or resolve includes.",
|
|
780
|
+
annotations: READ,
|
|
781
|
+
inputSchema: input({ path: HATCH_PATH_PROPERTY, query: QUERY_PROPERTY, version: VERSION_PROPERTY }, ["path"]),
|
|
782
|
+
outputSchema: {
|
|
783
|
+
type: "object",
|
|
784
|
+
properties: { result: { description: "Planning Center's untouched response body." } },
|
|
785
|
+
required: ["result"],
|
|
786
|
+
},
|
|
787
|
+
handler: async (args, ctx) => ({
|
|
788
|
+
result: (await call(compact({
|
|
789
|
+
method: "GET",
|
|
790
|
+
path: String(args["path"]),
|
|
791
|
+
query: queryPairs(args["query"]),
|
|
792
|
+
version: args["version"],
|
|
793
|
+
}), ctx)) ?? null,
|
|
794
|
+
}),
|
|
795
|
+
},
|
|
796
|
+
{
|
|
797
|
+
name: "pco_api_mutate",
|
|
798
|
+
description: "Call any Planning Center POST, PATCH, or DELETE endpoint with a JSON:API body. The approval-gated hatch for writes, actions, and money movement no named tool covers.",
|
|
799
|
+
annotations: DESTRUCTIVE,
|
|
800
|
+
inputSchema: input({
|
|
801
|
+
method: {
|
|
802
|
+
type: "string",
|
|
803
|
+
enum: ["POST", "PATCH", "DELETE"],
|
|
804
|
+
description: "Mutation method; Planning Center uses PATCH, never PUT, for updates.",
|
|
805
|
+
},
|
|
806
|
+
path: HATCH_PATH_PROPERTY,
|
|
807
|
+
query: QUERY_PROPERTY,
|
|
808
|
+
body: {
|
|
809
|
+
type: "object",
|
|
810
|
+
description: 'JSON:API document, never flat attributes: {"data":{"type":"Email","attributes":{...}}}. Omit for DELETE and bodiless actions.',
|
|
811
|
+
},
|
|
812
|
+
version: VERSION_PROPERTY,
|
|
813
|
+
}, ["method", "path"]),
|
|
814
|
+
outputSchema: {
|
|
815
|
+
type: "object",
|
|
816
|
+
properties: { result: { description: "Planning Center's untouched response body, or null for 204." } },
|
|
817
|
+
required: ["result"],
|
|
818
|
+
},
|
|
819
|
+
handler: async (args, ctx) => ({
|
|
820
|
+
result: (await call(compact({
|
|
821
|
+
method: args["method"],
|
|
822
|
+
path: String(args["path"]),
|
|
823
|
+
query: queryPairs(args["query"]),
|
|
824
|
+
body: args["body"],
|
|
825
|
+
version: args["version"],
|
|
826
|
+
}), ctx)) ?? null,
|
|
827
|
+
}),
|
|
828
|
+
},
|
|
829
|
+
// --- People -------------------------------------------------------------
|
|
830
|
+
{
|
|
831
|
+
name: "get_me",
|
|
832
|
+
description: "Get the Planning Center person this token acts as, with the organization's name and time zone. Supplies personId for my-schedule and assignee questions.",
|
|
833
|
+
annotations: READ,
|
|
834
|
+
inputSchema: input({}),
|
|
835
|
+
outputSchema: record({
|
|
836
|
+
id: STRING,
|
|
837
|
+
name: STRING,
|
|
838
|
+
first_name: STRING,
|
|
839
|
+
last_name: STRING,
|
|
840
|
+
status: STRING,
|
|
841
|
+
organization: record({ id: STRING, name: STRING, time_zone: STRING }),
|
|
842
|
+
}),
|
|
843
|
+
handler: async (_args, ctx) => {
|
|
844
|
+
const document = asRecord(await call({ method: "GET", path: "/people/v2/me", query: { include: "organization" } }, ctx));
|
|
845
|
+
const me = created(document, "person record");
|
|
846
|
+
const organization = resources(document["included"]).find((row) => row.type === "Organization");
|
|
847
|
+
return {
|
|
848
|
+
...pick(me, ["name", "first_name", "last_name", "status"]),
|
|
849
|
+
...(organization ? { organization: pick(organization, ["name", "time_zone"]) } : {}),
|
|
850
|
+
};
|
|
851
|
+
},
|
|
852
|
+
},
|
|
853
|
+
list({
|
|
854
|
+
name: "search_people",
|
|
855
|
+
description: "Search People profiles by name, email, or phone, with each person's primary email and phone. Omit search to list everyone; never searches custom fields or notes.",
|
|
856
|
+
key: "people",
|
|
857
|
+
item: PERSON_SUMMARY_SCHEMA,
|
|
858
|
+
properties: {
|
|
859
|
+
search: { type: "string", minLength: 1, description: "Matches name, email address, or phone number." },
|
|
860
|
+
status: { type: "string", enum: ["active", "inactive"], description: "Profile status." },
|
|
861
|
+
membership: { type: "string", minLength: 1, description: "Exact membership type, e.g. Member." },
|
|
862
|
+
updatedSince: dateProperty("Only people updated at or after this time."),
|
|
863
|
+
order: orderProperty(["last_name", "first_name", "created_at", "updated_at"]),
|
|
864
|
+
},
|
|
865
|
+
path: () => "/people/v2/people",
|
|
866
|
+
query: (args) => ({
|
|
867
|
+
"where[search_name_or_email_or_phone_number]": args["search"],
|
|
868
|
+
"where[status]": args["status"],
|
|
869
|
+
"where[membership]": args["membership"],
|
|
870
|
+
"where[updated_at][gte]": args["updatedSince"],
|
|
871
|
+
order: args["order"],
|
|
872
|
+
}),
|
|
873
|
+
include: "emails,phone_numbers",
|
|
874
|
+
project: personSummary,
|
|
875
|
+
}),
|
|
876
|
+
{
|
|
877
|
+
name: "get_person",
|
|
878
|
+
description: "Get one person's profile with every email, phone number, address, household, and primary campus. Custom fields are list_person_field_data.",
|
|
879
|
+
annotations: READ,
|
|
880
|
+
inputSchema: input({ personId: PERSON_ID, raw: RAW_PROPERTY }, ["personId"]),
|
|
881
|
+
outputSchema: PERSON_SCHEMA,
|
|
882
|
+
handler: async (args, ctx) => {
|
|
883
|
+
const document = asRecord(await call({
|
|
884
|
+
method: "GET",
|
|
885
|
+
path: `/people/v2/people/${segment(args["personId"])}`,
|
|
886
|
+
query: { include: "emails,phone_numbers,addresses,households,primary_campus" },
|
|
887
|
+
}, ctx));
|
|
888
|
+
const person = created(document, "person record");
|
|
889
|
+
return args["raw"] === true
|
|
890
|
+
? { ...person, included: asArray(document["included"]) }
|
|
891
|
+
: personRecord(person, indexIncluded(document));
|
|
892
|
+
},
|
|
893
|
+
},
|
|
894
|
+
{
|
|
895
|
+
name: "create_person",
|
|
896
|
+
description: "Create a People profile. Emails and phone numbers are separate records; add them after with add_person_email and add_person_phone_number.",
|
|
897
|
+
annotations: ADDITIVE,
|
|
898
|
+
inputSchema: input(PERSON_WRITE_PROPERTIES, ["firstName", "lastName"]),
|
|
899
|
+
outputSchema: PERSON_SUMMARY_SCHEMA,
|
|
900
|
+
handler: async (args, ctx) => {
|
|
901
|
+
const document = await call({ method: "POST", path: "/people/v2/people", body: writeDocument("Person", personAttributes(args)) }, ctx);
|
|
902
|
+
return personSummary(created(document, "person"), new Map());
|
|
903
|
+
},
|
|
904
|
+
},
|
|
905
|
+
{
|
|
906
|
+
name: "update_person",
|
|
907
|
+
description: "Update fields on one People profile; omitted fields stay as they are. status inactive archives the person reversibly; this never deletes a profile.",
|
|
908
|
+
annotations: DESTRUCTIVE,
|
|
909
|
+
inputSchema: input({
|
|
910
|
+
personId: PERSON_ID,
|
|
911
|
+
...PERSON_WRITE_PROPERTIES,
|
|
912
|
+
status: { type: "string", enum: ["active", "inactive"], description: "inactive archives the profile; active restores it." },
|
|
913
|
+
}, ["personId"]),
|
|
914
|
+
outputSchema: PERSON_SUMMARY_SCHEMA,
|
|
915
|
+
handler: async (args, ctx) => {
|
|
916
|
+
const attributes = compact(personAttributes(args));
|
|
917
|
+
if (Object.keys(attributes).length === 0) {
|
|
918
|
+
throw new ConnectorCallError("invalid_args", "Nothing to update: provide at least one profile field.");
|
|
919
|
+
}
|
|
920
|
+
const document = await call({
|
|
921
|
+
method: "PATCH",
|
|
922
|
+
path: `/people/v2/people/${segment(args["personId"])}`,
|
|
923
|
+
body: { data: { type: "Person", id: String(args["personId"]), attributes } },
|
|
924
|
+
}, ctx);
|
|
925
|
+
return personSummary(created(document, "person update"), new Map());
|
|
926
|
+
},
|
|
927
|
+
},
|
|
928
|
+
{
|
|
929
|
+
name: "add_person_email",
|
|
930
|
+
description: "Add an email address to a person. Making it primary demotes the current primary; edit or delete existing addresses with pco_api_mutate.",
|
|
931
|
+
annotations: ADDITIVE,
|
|
932
|
+
inputSchema: input({
|
|
933
|
+
personId: PERSON_ID,
|
|
934
|
+
address: { type: "string", minLength: 3, description: "Email address." },
|
|
935
|
+
location: { type: "string", minLength: 1, description: "Label such as Home or Work. Defaults to Home." },
|
|
936
|
+
primary: { type: "boolean", description: "Make this the primary address." },
|
|
937
|
+
}, ["personId", "address"]),
|
|
938
|
+
outputSchema: EMAIL_SCHEMA,
|
|
939
|
+
handler: async (args, ctx) => emailRecord(created(await call({
|
|
940
|
+
method: "POST",
|
|
941
|
+
path: `/people/v2/people/${segment(args["personId"])}/emails`,
|
|
942
|
+
body: writeDocument("Email", {
|
|
943
|
+
address: args["address"],
|
|
944
|
+
location: args["location"] ?? "Home",
|
|
945
|
+
primary: args["primary"],
|
|
946
|
+
}),
|
|
947
|
+
}, ctx), "email")),
|
|
948
|
+
},
|
|
949
|
+
{
|
|
950
|
+
name: "add_person_phone_number",
|
|
951
|
+
description: "Add a phone number to a person. Making it primary demotes the current primary; edit or delete existing numbers with pco_api_mutate.",
|
|
952
|
+
annotations: ADDITIVE,
|
|
953
|
+
inputSchema: input({
|
|
954
|
+
personId: PERSON_ID,
|
|
955
|
+
number: { type: "string", minLength: 3, description: "Phone number as dialed; Planning Center formats it." },
|
|
956
|
+
location: { type: "string", minLength: 1, description: "Label such as Mobile, Home, or Work. Defaults to Mobile." },
|
|
957
|
+
primary: { type: "boolean", description: "Make this the primary number." },
|
|
958
|
+
}, ["personId", "number"]),
|
|
959
|
+
outputSchema: PHONE_SCHEMA,
|
|
960
|
+
handler: async (args, ctx) => phoneRecord(created(await call({
|
|
961
|
+
method: "POST",
|
|
962
|
+
path: `/people/v2/people/${segment(args["personId"])}/phone_numbers`,
|
|
963
|
+
body: writeDocument("PhoneNumber", {
|
|
964
|
+
number: args["number"],
|
|
965
|
+
location: args["location"] ?? "Mobile",
|
|
966
|
+
primary: args["primary"],
|
|
967
|
+
}),
|
|
968
|
+
}, ctx), "phone number")),
|
|
969
|
+
},
|
|
970
|
+
list({
|
|
971
|
+
name: "list_lists",
|
|
972
|
+
description: "List People lists (saved, rule-based searches) with size and last refresh. Members come from list_list_people; results are only as fresh as refreshed_at.",
|
|
973
|
+
key: "lists",
|
|
974
|
+
item: record({
|
|
975
|
+
id: STRING,
|
|
976
|
+
name: NULLABLE_STRING,
|
|
977
|
+
description: NULLABLE_STRING,
|
|
978
|
+
total_people: INTEGER,
|
|
979
|
+
status: STRING,
|
|
980
|
+
refreshed_at: NULLABLE_STRING,
|
|
981
|
+
auto_refresh: BOOLEAN,
|
|
982
|
+
automations_active: BOOLEAN,
|
|
983
|
+
}),
|
|
984
|
+
properties: {
|
|
985
|
+
name: { type: "string", minLength: 1, description: "Exact list name; end it with % for a prefix match." },
|
|
986
|
+
order: orderProperty(["name", "updated_at", "refreshed_at"]),
|
|
987
|
+
},
|
|
988
|
+
path: () => "/people/v2/lists",
|
|
989
|
+
query: (args) => ({ "where[name]": args["name"], order: args["order"] }),
|
|
990
|
+
project: (row) => pick(row, ["name", "description", "total_people", "status", "refreshed_at", "auto_refresh", "automations_active"]),
|
|
991
|
+
}),
|
|
992
|
+
list({
|
|
993
|
+
name: "list_list_people",
|
|
994
|
+
description: "List the people in one People list as of its last refresh, with primary email and phone. Call run_list first when the results must be current.",
|
|
995
|
+
key: "people",
|
|
996
|
+
item: PERSON_SUMMARY_SCHEMA,
|
|
997
|
+
properties: { listId: idProperty("List id from list_lists.") },
|
|
998
|
+
required: ["listId"],
|
|
999
|
+
path: (args) => `/people/v2/lists/${segment(args["listId"])}/people`,
|
|
1000
|
+
include: "emails,phone_numbers",
|
|
1001
|
+
project: personSummary,
|
|
1002
|
+
}),
|
|
1003
|
+
{
|
|
1004
|
+
name: "run_list",
|
|
1005
|
+
description: "Refresh a People list's results now. Destructive: a refresh can fire the list's automations, such as adding people to workflows or forms.",
|
|
1006
|
+
annotations: DESTRUCTIVE,
|
|
1007
|
+
inputSchema: input({ listId: idProperty("List id from list_lists.") }, ["listId"]),
|
|
1008
|
+
outputSchema: record({ listId: STRING, queued: BOOLEAN }, ["listId", "queued"]),
|
|
1009
|
+
handler: async (args, ctx) => {
|
|
1010
|
+
await call({ method: "POST", path: `/people/v2/lists/${segment(args["listId"])}/run` }, ctx);
|
|
1011
|
+
return { listId: String(args["listId"]), queued: true };
|
|
1012
|
+
},
|
|
1013
|
+
},
|
|
1014
|
+
list({
|
|
1015
|
+
name: "list_workflows",
|
|
1016
|
+
description: "List People workflows with their steps and ready, overdue, and unassigned card counts. Archived workflows are excluded unless asked for.",
|
|
1017
|
+
key: "workflows",
|
|
1018
|
+
item: record({
|
|
1019
|
+
id: STRING,
|
|
1020
|
+
name: STRING,
|
|
1021
|
+
total_cards_count: INTEGER,
|
|
1022
|
+
total_ready_card_count: INTEGER,
|
|
1023
|
+
total_overdue_card_count: INTEGER,
|
|
1024
|
+
total_unassigned_card_count: INTEGER,
|
|
1025
|
+
my_ready_card_count: INTEGER,
|
|
1026
|
+
archived_at: NULLABLE_STRING,
|
|
1027
|
+
steps: ARRAY,
|
|
1028
|
+
}),
|
|
1029
|
+
properties: {
|
|
1030
|
+
name: { type: "string", minLength: 1, description: "Exact workflow name; end it with % for a prefix match." },
|
|
1031
|
+
includeArchived: { type: "boolean", description: "Include archived workflows." },
|
|
1032
|
+
},
|
|
1033
|
+
path: () => "/people/v2/workflows",
|
|
1034
|
+
query: (args) => ({
|
|
1035
|
+
"where[name]": args["name"],
|
|
1036
|
+
filter: args["includeArchived"] === true ? undefined : "not_archived",
|
|
1037
|
+
}),
|
|
1038
|
+
include: "steps",
|
|
1039
|
+
project: (row, index) => ({
|
|
1040
|
+
...pick(row, [
|
|
1041
|
+
"name",
|
|
1042
|
+
"total_cards_count",
|
|
1043
|
+
"total_ready_card_count",
|
|
1044
|
+
"total_overdue_card_count",
|
|
1045
|
+
"total_unassigned_card_count",
|
|
1046
|
+
"my_ready_card_count",
|
|
1047
|
+
"archived_at",
|
|
1048
|
+
]),
|
|
1049
|
+
steps: relatedMany(row, "steps", index)
|
|
1050
|
+
.map((step) => pick(step, ["name", "sequence"]))
|
|
1051
|
+
.sort((a, b) => Number(a["sequence"] ?? 0) - Number(b["sequence"] ?? 0)),
|
|
1052
|
+
}),
|
|
1053
|
+
}),
|
|
1054
|
+
list({
|
|
1055
|
+
name: "list_workflow_cards",
|
|
1056
|
+
description: "List the cards in one workflow with each card's person, assignee, and current step. Card actions need both the card id and its person_id.",
|
|
1057
|
+
key: "cards",
|
|
1058
|
+
item: CARD_SCHEMA,
|
|
1059
|
+
properties: {
|
|
1060
|
+
workflowId: idProperty("Workflow id from list_workflows."),
|
|
1061
|
+
stage: { type: "string", minLength: 1, description: "Card stage as Planning Center reports it, e.g. ready, snoozed, completed, removed." },
|
|
1062
|
+
stepId: idProperty("Only cards on this step (list_workflows steps[].id)."),
|
|
1063
|
+
assigneeId: idProperty("Only cards assigned to this person id."),
|
|
1064
|
+
overdue: { type: "boolean", description: "Only overdue (true) or on-time (false) cards." },
|
|
1065
|
+
order: orderProperty(["created_at", "moved_to_step_at", "last_name"]),
|
|
1066
|
+
},
|
|
1067
|
+
required: ["workflowId"],
|
|
1068
|
+
path: (args) => `/people/v2/workflows/${segment(args["workflowId"])}/cards`,
|
|
1069
|
+
query: (args) => ({
|
|
1070
|
+
"where[stage]": args["stage"],
|
|
1071
|
+
"where[step_id]": args["stepId"],
|
|
1072
|
+
"where[assignee_id]": args["assigneeId"],
|
|
1073
|
+
"where[overdue]": args["overdue"],
|
|
1074
|
+
order: args["order"],
|
|
1075
|
+
}),
|
|
1076
|
+
include: "person,current_step",
|
|
1077
|
+
project: cardRecord,
|
|
1078
|
+
}),
|
|
1079
|
+
{
|
|
1080
|
+
name: "add_workflow_card",
|
|
1081
|
+
description: "Add a person to a workflow as a new card on its first step. Optionally assign it; the workflow's own step defaults apply otherwise.",
|
|
1082
|
+
annotations: ADDITIVE,
|
|
1083
|
+
inputSchema: input({
|
|
1084
|
+
workflowId: idProperty("Workflow id from list_workflows."),
|
|
1085
|
+
personId: PERSON_ID,
|
|
1086
|
+
assigneeId: idProperty("Person id to assign the card to."),
|
|
1087
|
+
}, ["workflowId", "personId"]),
|
|
1088
|
+
outputSchema: CARD_SCHEMA,
|
|
1089
|
+
handler: async (args, ctx) => cardRecord(created(await call({
|
|
1090
|
+
method: "POST",
|
|
1091
|
+
path: `/people/v2/workflows/${segment(args["workflowId"])}/cards`,
|
|
1092
|
+
body: writeDocument("WorkflowCard", { person_id: args["personId"], assignee_id: args["assigneeId"] }),
|
|
1093
|
+
}, ctx), "workflow card"), new Map()),
|
|
1094
|
+
},
|
|
1095
|
+
{
|
|
1096
|
+
name: "apply_workflow_card_action",
|
|
1097
|
+
description: "Promote, send back, skip a step on, snooze, unsnooze, remove, or restore one workflow card. Never emails; sending a card email is pco_api_mutate.",
|
|
1098
|
+
annotations: DESTRUCTIVE,
|
|
1099
|
+
inputSchema: input({
|
|
1100
|
+
personId: { ...PERSON_ID, description: "The card's person_id from list_workflow_cards." },
|
|
1101
|
+
cardId: idProperty("Workflow card id from list_workflow_cards."),
|
|
1102
|
+
action: {
|
|
1103
|
+
type: "string",
|
|
1104
|
+
enum: [...WORKFLOW_CARD_ACTIONS],
|
|
1105
|
+
description: "promote completes the step; skip_step moves on without completing it; go_back returns to the previous step.",
|
|
1106
|
+
},
|
|
1107
|
+
snoozeDays: { type: "integer", minimum: 1, maximum: 365, description: "Days to snooze; required with action snooze and refused otherwise." },
|
|
1108
|
+
}, ["personId", "cardId", "action"]),
|
|
1109
|
+
outputSchema: record({ cardId: STRING, action: STRING, applied: BOOLEAN }, ["cardId", "action", "applied"]),
|
|
1110
|
+
handler: async (args, ctx) => {
|
|
1111
|
+
const snooze = args["action"] === "snooze";
|
|
1112
|
+
if (snooze !== (args["snoozeDays"] !== undefined)) {
|
|
1113
|
+
throw new ConnectorCallError("invalid_args", snooze ? "action snooze needs snoozeDays." : "snoozeDays applies only to action snooze.");
|
|
1114
|
+
}
|
|
1115
|
+
await call({
|
|
1116
|
+
method: "POST",
|
|
1117
|
+
path: `/people/v2/people/${segment(args["personId"])}/workflow_cards/${segment(args["cardId"])}/${String(args["action"])}`,
|
|
1118
|
+
...(snooze ? { body: { data: { attributes: { duration: args["snoozeDays"] } } } } : {}),
|
|
1119
|
+
}, ctx);
|
|
1120
|
+
return { cardId: String(args["cardId"]), action: String(args["action"]), applied: true };
|
|
1121
|
+
},
|
|
1122
|
+
},
|
|
1123
|
+
{
|
|
1124
|
+
name: "add_workflow_card_note",
|
|
1125
|
+
description: "Add a note to one workflow card's activity. Notifies nobody by itself and does not move the card.",
|
|
1126
|
+
annotations: ADDITIVE,
|
|
1127
|
+
inputSchema: input({
|
|
1128
|
+
personId: { ...PERSON_ID, description: "The card's person_id from list_workflow_cards." },
|
|
1129
|
+
cardId: idProperty("Workflow card id from list_workflow_cards."),
|
|
1130
|
+
note: { type: "string", minLength: 1, description: "Note text." },
|
|
1131
|
+
}, ["personId", "cardId", "note"]),
|
|
1132
|
+
outputSchema: record({ id: STRING, note: STRING, created_at: STRING }),
|
|
1133
|
+
handler: async (args, ctx) => pick(created(await call({
|
|
1134
|
+
method: "POST",
|
|
1135
|
+
path: `/people/v2/people/${segment(args["personId"])}/workflow_cards/${segment(args["cardId"])}/notes`,
|
|
1136
|
+
body: writeDocument("WorkflowCardNote", { note: args["note"] }),
|
|
1137
|
+
}, ctx), "card note"), ["note", "created_at"]),
|
|
1138
|
+
},
|
|
1139
|
+
list({
|
|
1140
|
+
name: "list_person_notes",
|
|
1141
|
+
description: "List the notes on one person's profile with their category names, newest first by default. Workflow card notes are not profile notes.",
|
|
1142
|
+
key: "notes",
|
|
1143
|
+
item: NOTE_SCHEMA,
|
|
1144
|
+
properties: {
|
|
1145
|
+
personId: PERSON_ID,
|
|
1146
|
+
noteCategoryId: idProperty("Only notes in this category (/people/v2/note_categories)."),
|
|
1147
|
+
order: orderProperty(["created_at", "display_date"]),
|
|
1148
|
+
},
|
|
1149
|
+
required: ["personId"],
|
|
1150
|
+
path: (args) => `/people/v2/people/${segment(args["personId"])}/notes`,
|
|
1151
|
+
query: (args) => ({ "where[note_category_id]": args["noteCategoryId"], order: args["order"] ?? "-created_at" }),
|
|
1152
|
+
include: "category",
|
|
1153
|
+
project: noteRecord,
|
|
1154
|
+
}),
|
|
1155
|
+
{
|
|
1156
|
+
name: "add_person_note",
|
|
1157
|
+
description: "Add a note to a person's profile in a note category. Category subscribers may be notified; read categories at /people/v2/note_categories.",
|
|
1158
|
+
annotations: ADDITIVE,
|
|
1159
|
+
inputSchema: input({
|
|
1160
|
+
personId: PERSON_ID,
|
|
1161
|
+
note: { type: "string", minLength: 1, description: "Note text." },
|
|
1162
|
+
noteCategoryId: idProperty("Note category id; Planning Center files every note under one."),
|
|
1163
|
+
}, ["personId", "note", "noteCategoryId"]),
|
|
1164
|
+
outputSchema: NOTE_SCHEMA,
|
|
1165
|
+
handler: async (args, ctx) => noteRecord(created(await call({
|
|
1166
|
+
method: "POST",
|
|
1167
|
+
path: `/people/v2/people/${segment(args["personId"])}/notes`,
|
|
1168
|
+
body: writeDocument("Note", { note: args["note"], note_category_id: args["noteCategoryId"] }),
|
|
1169
|
+
}, ctx), "note"), new Map()),
|
|
1170
|
+
},
|
|
1171
|
+
list({
|
|
1172
|
+
name: "list_person_field_data",
|
|
1173
|
+
description: "List one person's custom field values, each labeled with its field name and data type. Writing a value is pco_api_mutate at /people/v2/field_data.",
|
|
1174
|
+
key: "fields",
|
|
1175
|
+
item: record({
|
|
1176
|
+
id: STRING,
|
|
1177
|
+
field_definition_id: STRING,
|
|
1178
|
+
field: NULLABLE_STRING,
|
|
1179
|
+
data_type: NULLABLE_STRING,
|
|
1180
|
+
value: NULLABLE_STRING,
|
|
1181
|
+
file_name: NULLABLE_STRING,
|
|
1182
|
+
}),
|
|
1183
|
+
properties: { personId: PERSON_ID },
|
|
1184
|
+
required: ["personId"],
|
|
1185
|
+
path: (args) => `/people/v2/people/${segment(args["personId"])}/field_data`,
|
|
1186
|
+
include: "field_definition",
|
|
1187
|
+
project: (row, index) => {
|
|
1188
|
+
const definition = related(row, "field_definition", index);
|
|
1189
|
+
return {
|
|
1190
|
+
id: row.id,
|
|
1191
|
+
field_definition_id: relId(row, "field_definition") ?? null,
|
|
1192
|
+
field: attribute(definition, "name") ?? null,
|
|
1193
|
+
data_type: attribute(definition, "data_type") ?? null,
|
|
1194
|
+
value: attribute(row, "value") ?? null,
|
|
1195
|
+
file_name: attribute(row, "file_name") ?? null,
|
|
1196
|
+
};
|
|
1197
|
+
},
|
|
1198
|
+
}),
|
|
1199
|
+
list({
|
|
1200
|
+
name: "list_forms",
|
|
1201
|
+
description: "List People forms with their submission counts and public URLs. Archived forms are excluded unless asked for.",
|
|
1202
|
+
key: "forms",
|
|
1203
|
+
item: record({
|
|
1204
|
+
id: STRING,
|
|
1205
|
+
name: STRING,
|
|
1206
|
+
description: NULLABLE_STRING,
|
|
1207
|
+
active: BOOLEAN,
|
|
1208
|
+
submission_count: INTEGER,
|
|
1209
|
+
public_url: NULLABLE_STRING,
|
|
1210
|
+
archived_at: NULLABLE_STRING,
|
|
1211
|
+
}),
|
|
1212
|
+
properties: { includeArchived: { type: "boolean", description: "Include archived forms." } },
|
|
1213
|
+
path: () => "/people/v2/forms",
|
|
1214
|
+
query: (args) => ({ filter: args["includeArchived"] === true ? undefined : "not_archived" }),
|
|
1215
|
+
project: (row) => pick(row, ["name", "description", "active", "submission_count", "public_url", "archived_at"]),
|
|
1216
|
+
}),
|
|
1217
|
+
list({
|
|
1218
|
+
name: "list_form_submissions",
|
|
1219
|
+
description: "List one form's submissions with each answer labeled by its field, newest first by default. Read-only; the API cannot submit a form.",
|
|
1220
|
+
key: "submissions",
|
|
1221
|
+
item: record({
|
|
1222
|
+
id: STRING,
|
|
1223
|
+
created_at: STRING,
|
|
1224
|
+
person_id: NULLABLE_STRING,
|
|
1225
|
+
person_name: NULLABLE_STRING,
|
|
1226
|
+
values: { type: "array", items: record({ field: NULLABLE_STRING, value: NULLABLE_STRING }, []) },
|
|
1227
|
+
}),
|
|
1228
|
+
properties: {
|
|
1229
|
+
formId: idProperty("Form id from list_forms."),
|
|
1230
|
+
submittedAfter: dateProperty("Only submissions created at or after this time."),
|
|
1231
|
+
order: orderProperty(["created_at"]),
|
|
1232
|
+
},
|
|
1233
|
+
required: ["formId"],
|
|
1234
|
+
path: (args) => `/people/v2/forms/${segment(args["formId"])}/form_submissions`,
|
|
1235
|
+
query: (args) => ({ "where[created_at][gte]": args["submittedAfter"], order: args["order"] ?? "-created_at" }),
|
|
1236
|
+
include: "form_submission_values,form_fields,person",
|
|
1237
|
+
project: (row, index) => {
|
|
1238
|
+
const person = related(row, "person", index);
|
|
1239
|
+
const values = relatedMany(row, "form_submission_values", index);
|
|
1240
|
+
return {
|
|
1241
|
+
id: row.id,
|
|
1242
|
+
created_at: attribute(row, "created_at"),
|
|
1243
|
+
person_id: relId(row, "person") ?? null,
|
|
1244
|
+
person_name: attribute(person, "name") ?? null,
|
|
1245
|
+
values: values.map((value) => ({
|
|
1246
|
+
field: attribute(related(value, "form_field", index), "label") ?? null,
|
|
1247
|
+
value: attribute(value, "display_value") ?? attribute(value, "value") ?? null,
|
|
1248
|
+
})),
|
|
1249
|
+
};
|
|
1250
|
+
},
|
|
1251
|
+
}),
|
|
1252
|
+
// --- Services -----------------------------------------------------------
|
|
1253
|
+
list({
|
|
1254
|
+
name: "list_service_types",
|
|
1255
|
+
description: "List Services service types — the recurring services such as Sunday Morning. Supplies serviceTypeId for plans and teams.",
|
|
1256
|
+
key: "serviceTypes",
|
|
1257
|
+
item: record({ id: STRING, name: STRING, frequency: NULLABLE_STRING, sequence: NULLABLE_INTEGER, parent_id: NULLABLE_STRING }),
|
|
1258
|
+
path: () => "/services/v2/service_types",
|
|
1259
|
+
query: () => ({ order: "sequence" }),
|
|
1260
|
+
project: (row) => ({ ...pick(row, ["name", "frequency", "sequence"]), parent_id: relId(row, "parent") ?? null }),
|
|
1261
|
+
}),
|
|
1262
|
+
list({
|
|
1263
|
+
name: "list_plans",
|
|
1264
|
+
description: "List one service type's plans (individual services) by date. Defaults to upcoming plans, soonest first; get_plan returns the order of service and team.",
|
|
1265
|
+
key: "plans",
|
|
1266
|
+
item: record({
|
|
1267
|
+
id: STRING,
|
|
1268
|
+
title: NULLABLE_STRING,
|
|
1269
|
+
series_title: NULLABLE_STRING,
|
|
1270
|
+
dates: STRING,
|
|
1271
|
+
sort_date: NULLABLE_STRING,
|
|
1272
|
+
items_count: INTEGER,
|
|
1273
|
+
plan_people_count: INTEGER,
|
|
1274
|
+
needed_positions_count: INTEGER,
|
|
1275
|
+
planning_center_url: STRING,
|
|
1276
|
+
}),
|
|
1277
|
+
properties: {
|
|
1278
|
+
serviceTypeId: SERVICE_TYPE_ID,
|
|
1279
|
+
when: { type: "string", enum: ["future", "past", "all"], description: "future (default) sorts soonest first; past sorts most recent first." },
|
|
1280
|
+
},
|
|
1281
|
+
required: ["serviceTypeId"],
|
|
1282
|
+
path: (args) => `/services/v2/service_types/${segment(args["serviceTypeId"])}/plans`,
|
|
1283
|
+
query: (args) => {
|
|
1284
|
+
const when = args["when"] ?? "future";
|
|
1285
|
+
return {
|
|
1286
|
+
filter: when === "all" ? undefined : when,
|
|
1287
|
+
order: when === "past" ? "-sort_date" : "sort_date",
|
|
1288
|
+
};
|
|
1289
|
+
},
|
|
1290
|
+
project: (row) => pick(row, [
|
|
1291
|
+
"title",
|
|
1292
|
+
"series_title",
|
|
1293
|
+
"dates",
|
|
1294
|
+
"sort_date",
|
|
1295
|
+
"items_count",
|
|
1296
|
+
"plan_people_count",
|
|
1297
|
+
"needed_positions_count",
|
|
1298
|
+
"planning_center_url",
|
|
1299
|
+
]),
|
|
1300
|
+
}),
|
|
1301
|
+
{
|
|
1302
|
+
name: "get_plan",
|
|
1303
|
+
description: "Get one plan with its times, order of service, and scheduled team in a single call. Flags a section past 100 rows rather than dropping it silently.",
|
|
1304
|
+
annotations: READ,
|
|
1305
|
+
inputSchema: input({ serviceTypeId: SERVICE_TYPE_ID, planId: PLAN_ID, raw: RAW_PROPERTY }, ["serviceTypeId", "planId"]),
|
|
1306
|
+
outputSchema: {
|
|
1307
|
+
type: "object",
|
|
1308
|
+
properties: {
|
|
1309
|
+
plan: OBJECT,
|
|
1310
|
+
items: { type: "array", items: ITEM_SCHEMA },
|
|
1311
|
+
teamMembers: { type: "array", items: PLAN_PERSON_SCHEMA },
|
|
1312
|
+
itemsTruncated: BOOLEAN,
|
|
1313
|
+
teamMembersTruncated: BOOLEAN,
|
|
1314
|
+
},
|
|
1315
|
+
required: ["plan", "items", "teamMembers", "itemsTruncated", "teamMembersTruncated"],
|
|
1316
|
+
},
|
|
1317
|
+
handler: async (args, ctx) => {
|
|
1318
|
+
const base = `/services/v2/service_types/${segment(args["serviceTypeId"])}/plans/${segment(args["planId"])}`;
|
|
1319
|
+
const [planDocument, itemDocument, teamDocument] = (await Promise.all([
|
|
1320
|
+
call({ method: "GET", path: base, query: { include: "plan_times" } }, ctx),
|
|
1321
|
+
call({ method: "GET", path: `${base}/items`, query: { per_page: MAX_PAGE_SIZE } }, ctx),
|
|
1322
|
+
call({ method: "GET", path: `${base}/team_members`, query: { include: "team", per_page: MAX_PAGE_SIZE } }, ctx),
|
|
1323
|
+
])).map(asRecord);
|
|
1324
|
+
const plan = created(planDocument, "plan record");
|
|
1325
|
+
const items = resources(itemDocument["data"]);
|
|
1326
|
+
const members = resources(teamDocument["data"]);
|
|
1327
|
+
const truncation = {
|
|
1328
|
+
itemsTruncated: pageOf(itemDocument).hasMore,
|
|
1329
|
+
teamMembersTruncated: pageOf(teamDocument).hasMore,
|
|
1330
|
+
};
|
|
1331
|
+
if (args["raw"] === true) {
|
|
1332
|
+
return {
|
|
1333
|
+
plan: { ...plan, included: asArray(planDocument["included"]) },
|
|
1334
|
+
items,
|
|
1335
|
+
teamMembers: members,
|
|
1336
|
+
...truncation,
|
|
1337
|
+
};
|
|
1338
|
+
}
|
|
1339
|
+
const planIndex = indexIncluded(planDocument);
|
|
1340
|
+
const teamIndex = indexIncluded(teamDocument);
|
|
1341
|
+
return {
|
|
1342
|
+
plan: {
|
|
1343
|
+
...pick(plan, ["title", "series_title", "dates", "sort_date", "total_length", "needed_positions_count", "planning_center_url"]),
|
|
1344
|
+
times: relatedMany(plan, "plan_times", planIndex).map((time) => pick(time, ["name", "time_type", "starts_at", "ends_at"])),
|
|
1345
|
+
},
|
|
1346
|
+
items: items
|
|
1347
|
+
.map(itemRecord)
|
|
1348
|
+
.sort((a, b) => Number(a["sequence"] ?? 0) - Number(b["sequence"] ?? 0)),
|
|
1349
|
+
teamMembers: members.map((member) => planPersonRecord(member, teamIndex)),
|
|
1350
|
+
...truncation,
|
|
1351
|
+
};
|
|
1352
|
+
},
|
|
1353
|
+
},
|
|
1354
|
+
list({
|
|
1355
|
+
name: "list_teams",
|
|
1356
|
+
description: "List one service type's teams with their positions. Supplies teamId and position names for schedule_plan_person.",
|
|
1357
|
+
key: "teams",
|
|
1358
|
+
item: record({ id: STRING, name: STRING, archived_at: NULLABLE_STRING, positions: ARRAY }),
|
|
1359
|
+
properties: { serviceTypeId: SERVICE_TYPE_ID },
|
|
1360
|
+
required: ["serviceTypeId"],
|
|
1361
|
+
path: (args) => `/services/v2/service_types/${segment(args["serviceTypeId"])}/teams`,
|
|
1362
|
+
include: "team_positions",
|
|
1363
|
+
project: (row, index) => ({
|
|
1364
|
+
...pick(row, ["name", "archived_at"]),
|
|
1365
|
+
positions: relatedMany(row, "team_positions", index).map((position) => pick(position, ["name"])),
|
|
1366
|
+
}),
|
|
1367
|
+
}),
|
|
1368
|
+
list({
|
|
1369
|
+
name: "list_person_schedules",
|
|
1370
|
+
description: "List where one person is scheduled to serve across every service type, with team, position, and response. Defaults to upcoming.",
|
|
1371
|
+
key: "schedules",
|
|
1372
|
+
item: record({
|
|
1373
|
+
id: STRING,
|
|
1374
|
+
dates: STRING,
|
|
1375
|
+
sort_date: NULLABLE_STRING,
|
|
1376
|
+
service_type_name: STRING,
|
|
1377
|
+
team_name: NULLABLE_STRING,
|
|
1378
|
+
team_position_name: NULLABLE_STRING,
|
|
1379
|
+
status: STRING,
|
|
1380
|
+
decline_reason: NULLABLE_STRING,
|
|
1381
|
+
plan_id: NULLABLE_STRING,
|
|
1382
|
+
}),
|
|
1383
|
+
properties: {
|
|
1384
|
+
personId: PERSON_ID,
|
|
1385
|
+
when: { type: "string", enum: ["future", "past"], description: "future (default) or past schedules." },
|
|
1386
|
+
},
|
|
1387
|
+
required: ["personId"],
|
|
1388
|
+
path: (args) => `/services/v2/people/${segment(args["personId"])}/schedules`,
|
|
1389
|
+
query: (args) => ({ filter: args["when"] ?? "future", order: args["when"] === "past" ? "-starts_at" : "starts_at" }),
|
|
1390
|
+
project: (row) => ({
|
|
1391
|
+
...pick(row, ["dates", "sort_date", "service_type_name", "team_name", "team_position_name", "status", "decline_reason"]),
|
|
1392
|
+
plan_id: relId(row, "plan") ?? null,
|
|
1393
|
+
}),
|
|
1394
|
+
}),
|
|
1395
|
+
{
|
|
1396
|
+
name: "schedule_plan_person",
|
|
1397
|
+
description: "Schedule a person on a plan's team, unconfirmed by default. Prepares a notification only when asked; Planning Center sends it, not this call.",
|
|
1398
|
+
annotations: ADDITIVE,
|
|
1399
|
+
inputSchema: input({
|
|
1400
|
+
serviceTypeId: SERVICE_TYPE_ID,
|
|
1401
|
+
planId: PLAN_ID,
|
|
1402
|
+
personId: PERSON_ID,
|
|
1403
|
+
teamId: idProperty("Team id from list_teams."),
|
|
1404
|
+
teamPositionName: { type: "string", minLength: 1, description: "Position name from list_teams positions[].name." },
|
|
1405
|
+
status: { type: "string", enum: ["U", "C"], description: "U unconfirmed (default) or C confirmed." },
|
|
1406
|
+
prepareNotification: { type: "boolean", description: "Queue Planning Center's scheduling notification for this person." },
|
|
1407
|
+
notes: { type: "string", description: "Note shown to the person." },
|
|
1408
|
+
}, ["serviceTypeId", "planId", "personId", "teamId"]),
|
|
1409
|
+
outputSchema: PLAN_PERSON_SCHEMA,
|
|
1410
|
+
handler: async (args, ctx) => planPersonRecord(created(await call({
|
|
1411
|
+
method: "POST",
|
|
1412
|
+
path: `/services/v2/service_types/${segment(args["serviceTypeId"])}/plans/${segment(args["planId"])}/team_members`,
|
|
1413
|
+
body: writeDocument("PlanPerson", {
|
|
1414
|
+
person_id: args["personId"],
|
|
1415
|
+
team_id: args["teamId"],
|
|
1416
|
+
team_position_name: args["teamPositionName"],
|
|
1417
|
+
status: args["status"] ?? "U",
|
|
1418
|
+
prepare_notification: args["prepareNotification"],
|
|
1419
|
+
notes: args["notes"],
|
|
1420
|
+
}),
|
|
1421
|
+
}, ctx), "scheduled person"), new Map()),
|
|
1422
|
+
},
|
|
1423
|
+
{
|
|
1424
|
+
name: "add_plan_item",
|
|
1425
|
+
description: "Add an item — a song, header, or other element — to a plan's order of service. A songId makes a song item; media_ids, set through pco_api_mutate, make a media item.",
|
|
1426
|
+
annotations: ADDITIVE,
|
|
1427
|
+
inputSchema: input({
|
|
1428
|
+
serviceTypeId: SERVICE_TYPE_ID,
|
|
1429
|
+
planId: PLAN_ID,
|
|
1430
|
+
title: { type: "string", minLength: 1, description: "Item title; for a song, usually the song title." },
|
|
1431
|
+
itemType: {
|
|
1432
|
+
type: "string",
|
|
1433
|
+
enum: ["item", "header"],
|
|
1434
|
+
description: "header for a section header, fixed at creation; item (the default) for anything else. Song and media types follow from what is attached, never this.",
|
|
1435
|
+
},
|
|
1436
|
+
songId: idProperty("Song id from list_songs, for a song item."),
|
|
1437
|
+
arrangementId: idProperty("Arrangement id from list_song_arrangements."),
|
|
1438
|
+
keyId: idProperty("Key id from list_song_arrangements keys[].id."),
|
|
1439
|
+
length: { type: "integer", minimum: 0, maximum: 86_400, description: "Length in seconds." },
|
|
1440
|
+
sequence: { type: "integer", minimum: 0, description: "Position in the order; omit to append." },
|
|
1441
|
+
servicePosition: { type: "string", enum: ["pre", "during", "post"], description: "Before, during, or after the service. Defaults to during." },
|
|
1442
|
+
description: { type: "string", description: "Item description." },
|
|
1443
|
+
}, ["serviceTypeId", "planId", "title"]),
|
|
1444
|
+
outputSchema: ITEM_SCHEMA,
|
|
1445
|
+
handler: async (args, ctx) => itemRecord(created(await call({
|
|
1446
|
+
method: "POST",
|
|
1447
|
+
path: `/services/v2/service_types/${segment(args["serviceTypeId"])}/plans/${segment(args["planId"])}/items`,
|
|
1448
|
+
body: writeDocument("Item", {
|
|
1449
|
+
title: args["title"],
|
|
1450
|
+
// Planning Center assigns only `header`; omitting it is `item`.
|
|
1451
|
+
item_type: args["itemType"] === "header" ? "header" : undefined,
|
|
1452
|
+
song_id: args["songId"],
|
|
1453
|
+
arrangement_id: args["arrangementId"],
|
|
1454
|
+
key_id: args["keyId"],
|
|
1455
|
+
length: args["length"],
|
|
1456
|
+
sequence: args["sequence"],
|
|
1457
|
+
service_position: args["servicePosition"],
|
|
1458
|
+
description: args["description"],
|
|
1459
|
+
}),
|
|
1460
|
+
}, ctx), "plan item")),
|
|
1461
|
+
},
|
|
1462
|
+
list({
|
|
1463
|
+
name: "list_songs",
|
|
1464
|
+
description: "Search the Services song library by title, author, CCLI number, or theme. Lyrics and chord charts live on arrangements.",
|
|
1465
|
+
key: "songs",
|
|
1466
|
+
item: record({
|
|
1467
|
+
id: STRING,
|
|
1468
|
+
title: STRING,
|
|
1469
|
+
author: NULLABLE_STRING,
|
|
1470
|
+
ccli_number: NULLABLE_INTEGER,
|
|
1471
|
+
themes: NULLABLE_STRING,
|
|
1472
|
+
hidden: BOOLEAN,
|
|
1473
|
+
last_scheduled_at: NULLABLE_STRING,
|
|
1474
|
+
}),
|
|
1475
|
+
properties: {
|
|
1476
|
+
title: { type: "string", minLength: 1, description: "Song title; end it with % for a prefix match." },
|
|
1477
|
+
author: { type: "string", minLength: 1, description: "Author." },
|
|
1478
|
+
ccliNumber: { type: "integer", minimum: 1, description: "CCLI song number." },
|
|
1479
|
+
theme: { type: "string", minLength: 1, description: "Theme." },
|
|
1480
|
+
order: orderProperty(["title", "last_scheduled_at"]),
|
|
1481
|
+
},
|
|
1482
|
+
path: () => "/services/v2/songs",
|
|
1483
|
+
query: (args) => ({
|
|
1484
|
+
"where[title]": args["title"],
|
|
1485
|
+
"where[author]": args["author"],
|
|
1486
|
+
"where[ccli_number]": args["ccliNumber"],
|
|
1487
|
+
"where[themes]": args["theme"],
|
|
1488
|
+
order: args["order"],
|
|
1489
|
+
}),
|
|
1490
|
+
project: (row) => pick(row, ["title", "author", "ccli_number", "themes", "hidden", "last_scheduled_at"]),
|
|
1491
|
+
}),
|
|
1492
|
+
list({
|
|
1493
|
+
name: "list_song_arrangements",
|
|
1494
|
+
description: "List one song's arrangements with tempo, meter, sequence, and keys. Supplies arrangementId and keyId for add_plan_item.",
|
|
1495
|
+
key: "arrangements",
|
|
1496
|
+
item: record({
|
|
1497
|
+
id: STRING,
|
|
1498
|
+
name: STRING,
|
|
1499
|
+
bpm: { type: ["number", "null"] },
|
|
1500
|
+
meter: NULLABLE_STRING,
|
|
1501
|
+
length: NULLABLE_INTEGER,
|
|
1502
|
+
chord_chart_key: NULLABLE_STRING,
|
|
1503
|
+
sequence_short: ARRAY,
|
|
1504
|
+
keys: ARRAY,
|
|
1505
|
+
}),
|
|
1506
|
+
properties: { songId: idProperty("Song id from list_songs.") },
|
|
1507
|
+
required: ["songId"],
|
|
1508
|
+
path: (args) => `/services/v2/songs/${segment(args["songId"])}/arrangements`,
|
|
1509
|
+
include: "keys",
|
|
1510
|
+
project: (row, index) => ({
|
|
1511
|
+
...pick(row, ["name", "bpm", "meter", "length", "chord_chart_key", "sequence_short"]),
|
|
1512
|
+
keys: relatedMany(row, "keys", index).map((key) => pick(key, ["name", "starting_key", "ending_key"])),
|
|
1513
|
+
}),
|
|
1514
|
+
}),
|
|
1515
|
+
// --- Groups -------------------------------------------------------------
|
|
1516
|
+
list({
|
|
1517
|
+
name: "list_groups",
|
|
1518
|
+
description: "List Groups groups with type, schedule, and member count. Archived groups are excluded unless asked for; the API cannot create a group.",
|
|
1519
|
+
key: "groups",
|
|
1520
|
+
item: record({
|
|
1521
|
+
id: STRING,
|
|
1522
|
+
name: STRING,
|
|
1523
|
+
memberships_count: INTEGER,
|
|
1524
|
+
schedule: NULLABLE_STRING,
|
|
1525
|
+
group_type_id: NULLABLE_STRING,
|
|
1526
|
+
contact_email: NULLABLE_STRING,
|
|
1527
|
+
archived_at: NULLABLE_STRING,
|
|
1528
|
+
public_church_center_web_url: NULLABLE_STRING,
|
|
1529
|
+
}),
|
|
1530
|
+
properties: {
|
|
1531
|
+
name: { type: "string", minLength: 1, description: "Exact group name; end it with % for a prefix match." },
|
|
1532
|
+
groupTypeId: idProperty("Only groups of this type (/groups/v2/group_types)."),
|
|
1533
|
+
archived: { type: "string", enum: ["exclude", "include", "only"], description: "Archived groups: exclude (default), include, or only." },
|
|
1534
|
+
order: orderProperty(["name", "created_at", "memberships_count"]),
|
|
1535
|
+
},
|
|
1536
|
+
path: () => "/groups/v2/groups",
|
|
1537
|
+
query: (args) => ({
|
|
1538
|
+
"where[name]": args["name"],
|
|
1539
|
+
"where[group_type][id]": args["groupTypeId"],
|
|
1540
|
+
"where[archive_status]": args["archived"] === "include" || args["archived"] === "only" ? args["archived"] : undefined,
|
|
1541
|
+
order: args["order"],
|
|
1542
|
+
}),
|
|
1543
|
+
project: (row) => ({
|
|
1544
|
+
...pick(row, ["name", "memberships_count", "schedule", "contact_email", "archived_at", "public_church_center_web_url"]),
|
|
1545
|
+
group_type_id: relId(row, "group_type") ?? null,
|
|
1546
|
+
}),
|
|
1547
|
+
}),
|
|
1548
|
+
list({
|
|
1549
|
+
name: "list_group_memberships",
|
|
1550
|
+
description: "List one group's members and leaders with their names and join dates. Contact details come from get_person with person_id.",
|
|
1551
|
+
key: "memberships",
|
|
1552
|
+
item: MEMBERSHIP_SCHEMA,
|
|
1553
|
+
properties: {
|
|
1554
|
+
groupId: idProperty("Group id from list_groups."),
|
|
1555
|
+
role: { type: "string", enum: ["member", "leader"], description: "Only members or only leaders." },
|
|
1556
|
+
order: orderProperty(["last_name", "first_name", "joined_at", "role"]),
|
|
1557
|
+
},
|
|
1558
|
+
required: ["groupId"],
|
|
1559
|
+
path: (args) => `/groups/v2/groups/${segment(args["groupId"])}/memberships`,
|
|
1560
|
+
query: (args) => ({ "where[role]": args["role"], order: args["order"] }),
|
|
1561
|
+
include: "person",
|
|
1562
|
+
project: membershipRecord,
|
|
1563
|
+
}),
|
|
1564
|
+
{
|
|
1565
|
+
name: "add_group_member",
|
|
1566
|
+
description: "Add a person to a group as a member or leader. Fails when they already belong; change a role or remove someone with pco_api_mutate.",
|
|
1567
|
+
annotations: ADDITIVE,
|
|
1568
|
+
inputSchema: input({
|
|
1569
|
+
groupId: idProperty("Group id from list_groups."),
|
|
1570
|
+
personId: PERSON_ID,
|
|
1571
|
+
role: { type: "string", enum: ["member", "leader"], description: "Defaults to member." },
|
|
1572
|
+
joinedAt: dateProperty("When they joined. Defaults to now."),
|
|
1573
|
+
}, ["groupId", "personId"]),
|
|
1574
|
+
outputSchema: MEMBERSHIP_SCHEMA,
|
|
1575
|
+
handler: async (args, ctx) => {
|
|
1576
|
+
const document = asRecord(await call({
|
|
1577
|
+
method: "POST",
|
|
1578
|
+
path: `/groups/v2/groups/${segment(args["groupId"])}/memberships`,
|
|
1579
|
+
query: { include: "person" },
|
|
1580
|
+
body: writeDocument("Membership", {
|
|
1581
|
+
person_id: args["personId"],
|
|
1582
|
+
role: args["role"] ?? "member",
|
|
1583
|
+
joined_at: args["joinedAt"],
|
|
1584
|
+
}),
|
|
1585
|
+
}, ctx));
|
|
1586
|
+
return membershipRecord(created(document, "membership"), indexIncluded(document));
|
|
1587
|
+
},
|
|
1588
|
+
},
|
|
1589
|
+
list({
|
|
1590
|
+
name: "list_group_events",
|
|
1591
|
+
description: "List Groups meetings in a date range, for one group or all groups. Read-only: the API cannot create events or record attendance.",
|
|
1592
|
+
key: "events",
|
|
1593
|
+
item: record({
|
|
1594
|
+
id: STRING,
|
|
1595
|
+
name: STRING,
|
|
1596
|
+
starts_at: STRING,
|
|
1597
|
+
ends_at: NULLABLE_STRING,
|
|
1598
|
+
canceled: BOOLEAN,
|
|
1599
|
+
group_id: NULLABLE_STRING,
|
|
1600
|
+
visitors_count: NULLABLE_INTEGER,
|
|
1601
|
+
virtual_location_url: NULLABLE_STRING,
|
|
1602
|
+
}),
|
|
1603
|
+
properties: {
|
|
1604
|
+
groupId: idProperty("One group's events. Omit for every group."),
|
|
1605
|
+
startsAfter: dateProperty("Only events starting at or after this time."),
|
|
1606
|
+
startsBefore: dateProperty("Only events starting before this time."),
|
|
1607
|
+
includeCanceled: { type: "boolean", description: "Include canceled events." },
|
|
1608
|
+
},
|
|
1609
|
+
path: (args) => args["groupId"] ? `/groups/v2/groups/${segment(args["groupId"])}/events` : "/groups/v2/events",
|
|
1610
|
+
query: (args) => ({
|
|
1611
|
+
...range(["starts_at"], args["startsAfter"], args["startsBefore"]),
|
|
1612
|
+
filter: args["includeCanceled"] === true ? undefined : "not_canceled",
|
|
1613
|
+
order: "starts_at",
|
|
1614
|
+
}),
|
|
1615
|
+
project: (row) => ({
|
|
1616
|
+
...pick(row, ["name", "starts_at", "ends_at", "canceled", "visitors_count", "virtual_location_url"]),
|
|
1617
|
+
group_id: relId(row, "group") ?? null,
|
|
1618
|
+
}),
|
|
1619
|
+
}),
|
|
1620
|
+
// --- Check-Ins (read-only product) ---------------------------------------
|
|
1621
|
+
list({
|
|
1622
|
+
name: "list_check_in_events",
|
|
1623
|
+
description: "List Check-Ins events, the recurring things people check in to. Supplies eventId for event periods and check-ins.",
|
|
1624
|
+
key: "events",
|
|
1625
|
+
item: record({ id: STRING, name: STRING, frequency: NULLABLE_STRING, archived_at: NULLABLE_STRING }),
|
|
1626
|
+
properties: {
|
|
1627
|
+
name: { type: "string", minLength: 1, description: "Exact event name; end it with % for a prefix match." },
|
|
1628
|
+
includeArchived: { type: "boolean", description: "Include archived events." },
|
|
1629
|
+
},
|
|
1630
|
+
path: () => "/check-ins/v2/events",
|
|
1631
|
+
query: (args) => ({ "where[name]": args["name"], filter: args["includeArchived"] === true ? undefined : "not_archived" }),
|
|
1632
|
+
project: (row) => pick(row, ["name", "frequency", "archived_at"]),
|
|
1633
|
+
}),
|
|
1634
|
+
list({
|
|
1635
|
+
name: "list_check_in_event_periods",
|
|
1636
|
+
description: "List one Check-Ins event's dated occurrences with regular, guest, and volunteer counts — the cheapest attendance answer. Newest first.",
|
|
1637
|
+
key: "periods",
|
|
1638
|
+
item: record({
|
|
1639
|
+
id: STRING,
|
|
1640
|
+
starts_at: STRING,
|
|
1641
|
+
ends_at: NULLABLE_STRING,
|
|
1642
|
+
regular_count: INTEGER,
|
|
1643
|
+
guest_count: INTEGER,
|
|
1644
|
+
volunteer_count: INTEGER,
|
|
1645
|
+
unique_total_count: NULLABLE_INTEGER,
|
|
1646
|
+
note: NULLABLE_STRING,
|
|
1647
|
+
}),
|
|
1648
|
+
properties: {
|
|
1649
|
+
eventId: idProperty("Check-Ins event id from list_check_in_events."),
|
|
1650
|
+
startsAfter: dateProperty("Only periods starting at or after this time."),
|
|
1651
|
+
startsBefore: dateProperty("Only periods starting before this time."),
|
|
1652
|
+
},
|
|
1653
|
+
required: ["eventId"],
|
|
1654
|
+
path: (args) => `/check-ins/v2/events/${segment(args["eventId"])}/event_periods`,
|
|
1655
|
+
query: (args) => ({ ...range(["starts_at"], args["startsAfter"], args["startsBefore"]), order: "-starts_at" }),
|
|
1656
|
+
project: (row) => pick(row, ["starts_at", "ends_at", "regular_count", "guest_count", "volunteer_count", "unique_total_count", "note"]),
|
|
1657
|
+
}),
|
|
1658
|
+
list({
|
|
1659
|
+
name: "list_check_ins",
|
|
1660
|
+
description: "List individual check-ins, scoped by service date through the event period. Read-only: the API cannot check anyone in or out.",
|
|
1661
|
+
key: "checkIns",
|
|
1662
|
+
item: record({
|
|
1663
|
+
id: STRING,
|
|
1664
|
+
first_name: STRING,
|
|
1665
|
+
last_name: STRING,
|
|
1666
|
+
kind: STRING,
|
|
1667
|
+
number: NULLABLE_INTEGER,
|
|
1668
|
+
security_code: NULLABLE_STRING,
|
|
1669
|
+
created_at: STRING,
|
|
1670
|
+
checked_out_at: NULLABLE_STRING,
|
|
1671
|
+
one_time_guest: BOOLEAN,
|
|
1672
|
+
person_id: NULLABLE_STRING,
|
|
1673
|
+
event_period_id: NULLABLE_STRING,
|
|
1674
|
+
}),
|
|
1675
|
+
properties: {
|
|
1676
|
+
eventId: idProperty("Only this Check-Ins event. Omit for every event."),
|
|
1677
|
+
serviceAfter: dateProperty("Only check-ins whose event period starts at or after this time."),
|
|
1678
|
+
serviceBefore: dateProperty("Only check-ins whose event period starts before this time."),
|
|
1679
|
+
kind: { type: "string", enum: ["regular", "guest", "volunteer"], description: "Only this kind of check-in." },
|
|
1680
|
+
order: orderProperty(["created_at", "last_name", "first_name"]),
|
|
1681
|
+
},
|
|
1682
|
+
path: (args) => args["eventId"] ? `/check-ins/v2/events/${segment(args["eventId"])}/check_ins` : "/check-ins/v2/check_ins",
|
|
1683
|
+
query: (args) => ({
|
|
1684
|
+
...range(["event_period", "starts_at"], args["serviceAfter"], args["serviceBefore"]),
|
|
1685
|
+
filter: args["kind"],
|
|
1686
|
+
order: args["order"],
|
|
1687
|
+
}),
|
|
1688
|
+
project: (row) => ({
|
|
1689
|
+
...pick(row, ["first_name", "last_name", "kind", "number", "security_code", "created_at", "checked_out_at", "one_time_guest"]),
|
|
1690
|
+
person_id: relId(row, "person") ?? null,
|
|
1691
|
+
event_period_id: relId(row, "event_period") ?? null,
|
|
1692
|
+
}),
|
|
1693
|
+
}),
|
|
1694
|
+
// --- Calendar (events read-only) ----------------------------------------
|
|
1695
|
+
list({
|
|
1696
|
+
name: "list_calendar_event_instances",
|
|
1697
|
+
description: "List what is on the Calendar in a date range: each occurrence with times and location, recurrence expanded. Events cannot be written through the API.",
|
|
1698
|
+
key: "instances",
|
|
1699
|
+
item: record({
|
|
1700
|
+
id: STRING,
|
|
1701
|
+
name: NULLABLE_STRING,
|
|
1702
|
+
starts_at: STRING,
|
|
1703
|
+
ends_at: STRING,
|
|
1704
|
+
all_day_event: BOOLEAN,
|
|
1705
|
+
location: NULLABLE_STRING,
|
|
1706
|
+
kind: NULLABLE_STRING,
|
|
1707
|
+
recurrence_description: NULLABLE_STRING,
|
|
1708
|
+
event_id: NULLABLE_STRING,
|
|
1709
|
+
church_center_url: NULLABLE_STRING,
|
|
1710
|
+
}),
|
|
1711
|
+
properties: {
|
|
1712
|
+
startsAfter: dateProperty("Only occurrences starting at or after this time."),
|
|
1713
|
+
startsBefore: dateProperty("Only occurrences starting before this time."),
|
|
1714
|
+
eventName: { type: "string", minLength: 1, description: "Exact event name; end it with % for a prefix match." },
|
|
1715
|
+
},
|
|
1716
|
+
path: () => "/calendar/v2/event_instances",
|
|
1717
|
+
query: (args) => ({
|
|
1718
|
+
...range(["starts_at"], args["startsAfter"], args["startsBefore"]),
|
|
1719
|
+
"where[event_name]": args["eventName"],
|
|
1720
|
+
order: "starts_at",
|
|
1721
|
+
}),
|
|
1722
|
+
project: (row) => ({
|
|
1723
|
+
...pick(row, ["name", "starts_at", "ends_at", "all_day_event", "location", "kind", "recurrence_description", "church_center_url"]),
|
|
1724
|
+
event_id: relId(row, "event") ?? null,
|
|
1725
|
+
}),
|
|
1726
|
+
}),
|
|
1727
|
+
// --- Registrations (read-only product) ----------------------------------
|
|
1728
|
+
list({
|
|
1729
|
+
name: "list_signups",
|
|
1730
|
+
description: "List Registrations signups (registration events) with open, close, and capacity state. Read-only: the API cannot register anyone.",
|
|
1731
|
+
key: "signups",
|
|
1732
|
+
item: record({
|
|
1733
|
+
id: STRING,
|
|
1734
|
+
name: STRING,
|
|
1735
|
+
open: BOOLEAN,
|
|
1736
|
+
closed: BOOLEAN,
|
|
1737
|
+
open_at: NULLABLE_STRING,
|
|
1738
|
+
close_at: NULLABLE_STRING,
|
|
1739
|
+
maximum_capacity: NULLABLE_INTEGER,
|
|
1740
|
+
at_maximum_capacity: BOOLEAN,
|
|
1741
|
+
archived: BOOLEAN,
|
|
1742
|
+
new_registration_url: NULLABLE_STRING,
|
|
1743
|
+
}),
|
|
1744
|
+
properties: {
|
|
1745
|
+
archived: { type: "string", enum: ["unarchived", "archived", "all"], description: "unarchived (default), archived, or all." },
|
|
1746
|
+
},
|
|
1747
|
+
path: () => "/registrations/v2/signups",
|
|
1748
|
+
query: (args) => ({ filter: args["archived"] === "all" ? undefined : (args["archived"] ?? "unarchived") }),
|
|
1749
|
+
project: (row) => pick(row, ["name", "open", "closed", "open_at", "close_at", "maximum_capacity", "at_maximum_capacity", "archived", "new_registration_url"]),
|
|
1750
|
+
}),
|
|
1751
|
+
list({
|
|
1752
|
+
name: "list_signup_attendees",
|
|
1753
|
+
description: "List one signup's attendees with active, waitlisted, and canceled state. person_id joins them to People.",
|
|
1754
|
+
key: "attendees",
|
|
1755
|
+
item: record({
|
|
1756
|
+
id: STRING,
|
|
1757
|
+
name: NULLABLE_STRING,
|
|
1758
|
+
active: BOOLEAN,
|
|
1759
|
+
waitlisted: BOOLEAN,
|
|
1760
|
+
canceled: BOOLEAN,
|
|
1761
|
+
complete: BOOLEAN,
|
|
1762
|
+
created_at: STRING,
|
|
1763
|
+
person_id: NULLABLE_STRING,
|
|
1764
|
+
}),
|
|
1765
|
+
properties: {
|
|
1766
|
+
signupId: idProperty("Signup id from list_signups."),
|
|
1767
|
+
status: { type: "string", enum: ["active", "waitlist", "canceled"], description: "Only attendees in this state." },
|
|
1768
|
+
},
|
|
1769
|
+
required: ["signupId"],
|
|
1770
|
+
path: (args) => `/registrations/v2/signups/${segment(args["signupId"])}/attendees`,
|
|
1771
|
+
query: (args) => ({ filter: args["status"] }),
|
|
1772
|
+
include: "person",
|
|
1773
|
+
project: (row) => ({
|
|
1774
|
+
...pick(row, ["name", "active", "waitlisted", "canceled", "complete", "created_at"]),
|
|
1775
|
+
person_id: relId(row, "person") ?? null,
|
|
1776
|
+
}),
|
|
1777
|
+
}),
|
|
1778
|
+
// --- Giving (reads; money moves only through pco_api_mutate) -------------
|
|
1779
|
+
list({
|
|
1780
|
+
name: "list_donations",
|
|
1781
|
+
description: "List Giving donations with their fund designations, newest first, by date, fund, or donor. Amounts are integer cents. Read-only.",
|
|
1782
|
+
key: "donations",
|
|
1783
|
+
item: DONATION_SCHEMA,
|
|
1784
|
+
properties: {
|
|
1785
|
+
personId: idProperty("Only this donor's donations."),
|
|
1786
|
+
receivedAfter: dateProperty("Only donations received at or after this time."),
|
|
1787
|
+
receivedBefore: dateProperty("Only donations received before this time."),
|
|
1788
|
+
fundId: idProperty("Only donations designated to this fund (list_funds)."),
|
|
1789
|
+
succeededOnly: { type: "boolean", description: "Exclude pending and failed card or ACH payments." },
|
|
1790
|
+
order: orderProperty(["received_at", "created_at", "updated_at"]),
|
|
1791
|
+
},
|
|
1792
|
+
path: (args) => args["personId"] ? `/giving/v2/people/${segment(args["personId"])}/donations` : "/giving/v2/donations",
|
|
1793
|
+
query: (args) => ({
|
|
1794
|
+
...range(["received_at"], args["receivedAfter"], args["receivedBefore"]),
|
|
1795
|
+
"where[fund_id]": args["fundId"],
|
|
1796
|
+
filter: args["succeededOnly"] === true ? "succeeded" : undefined,
|
|
1797
|
+
order: args["order"] ?? "-received_at",
|
|
1798
|
+
}),
|
|
1799
|
+
include: "designations,designations.fund",
|
|
1800
|
+
project: donationRecord,
|
|
1801
|
+
}),
|
|
1802
|
+
list({
|
|
1803
|
+
name: "list_funds",
|
|
1804
|
+
description: "List Giving funds with ledger codes and visibility. Supplies fundId for donation filters.",
|
|
1805
|
+
key: "funds",
|
|
1806
|
+
item: record({ id: STRING, name: STRING, ledger_code: NULLABLE_STRING, visibility: STRING, default: BOOLEAN, description: NULLABLE_STRING }),
|
|
1807
|
+
path: () => "/giving/v2/funds",
|
|
1808
|
+
project: (row) => pick(row, ["name", "ledger_code", "visibility", "default", "description"]),
|
|
1809
|
+
}),
|
|
1810
|
+
list({
|
|
1811
|
+
name: "list_batches",
|
|
1812
|
+
description: "List Giving batches with totals and commit state, most recently updated first. Committing a batch is pco_api_mutate.",
|
|
1813
|
+
key: "batches",
|
|
1814
|
+
item: record({
|
|
1815
|
+
id: STRING,
|
|
1816
|
+
description: NULLABLE_STRING,
|
|
1817
|
+
status: STRING,
|
|
1818
|
+
donations_count: INTEGER,
|
|
1819
|
+
total_cents: INTEGER,
|
|
1820
|
+
total_currency: STRING,
|
|
1821
|
+
committed_at: NULLABLE_STRING,
|
|
1822
|
+
created_at: STRING,
|
|
1823
|
+
batch_group_id: NULLABLE_STRING,
|
|
1824
|
+
}),
|
|
1825
|
+
properties: {
|
|
1826
|
+
status: { type: "string", enum: ["committed", "in_progress"], description: "Only committed or in-progress batches." },
|
|
1827
|
+
},
|
|
1828
|
+
path: () => "/giving/v2/batches",
|
|
1829
|
+
query: (args) => ({ filter: args["status"], order: "-updated_at" }),
|
|
1830
|
+
project: (row) => ({
|
|
1831
|
+
...pick(row, ["description", "status", "donations_count", "total_cents", "total_currency", "committed_at", "created_at"]),
|
|
1832
|
+
batch_group_id: relId(row, "batch_group") ?? null,
|
|
1833
|
+
}),
|
|
1834
|
+
}),
|
|
1835
|
+
list({
|
|
1836
|
+
name: "list_pledge_campaigns",
|
|
1837
|
+
description: "List Giving pledge campaigns with goal and received totals in cents. Supplies pledgeCampaignId for list_pledges.",
|
|
1838
|
+
key: "campaigns",
|
|
1839
|
+
item: record({
|
|
1840
|
+
id: STRING,
|
|
1841
|
+
name: STRING,
|
|
1842
|
+
starts_at: NULLABLE_STRING,
|
|
1843
|
+
ends_at: NULLABLE_STRING,
|
|
1844
|
+
goal_cents: NULLABLE_INTEGER,
|
|
1845
|
+
received_total_from_pledges_cents: INTEGER,
|
|
1846
|
+
received_total_outside_of_pledges_cents: INTEGER,
|
|
1847
|
+
fund_id: NULLABLE_STRING,
|
|
1848
|
+
}),
|
|
1849
|
+
path: () => "/giving/v2/pledge_campaigns",
|
|
1850
|
+
query: () => ({ order: "-starts_at" }),
|
|
1851
|
+
project: (row) => ({
|
|
1852
|
+
...pick(row, [
|
|
1853
|
+
"name",
|
|
1854
|
+
"starts_at",
|
|
1855
|
+
"ends_at",
|
|
1856
|
+
"goal_cents",
|
|
1857
|
+
"received_total_from_pledges_cents",
|
|
1858
|
+
"received_total_outside_of_pledges_cents",
|
|
1859
|
+
]),
|
|
1860
|
+
fund_id: relId(row, "fund") ?? null,
|
|
1861
|
+
}),
|
|
1862
|
+
}),
|
|
1863
|
+
list({
|
|
1864
|
+
name: "list_pledges",
|
|
1865
|
+
description: "List the pledges in one Giving pledge campaign with pledged and donated totals in cents per donor.",
|
|
1866
|
+
key: "pledges",
|
|
1867
|
+
item: record({
|
|
1868
|
+
id: STRING,
|
|
1869
|
+
amount_cents: INTEGER,
|
|
1870
|
+
donated_total_cents: INTEGER,
|
|
1871
|
+
joint_giver_amount_cents: NULLABLE_INTEGER,
|
|
1872
|
+
person_id: NULLABLE_STRING,
|
|
1873
|
+
created_at: STRING,
|
|
1874
|
+
}),
|
|
1875
|
+
properties: { pledgeCampaignId: idProperty("Pledge campaign id from list_pledge_campaigns.") },
|
|
1876
|
+
required: ["pledgeCampaignId"],
|
|
1877
|
+
path: (args) => `/giving/v2/pledge_campaigns/${segment(args["pledgeCampaignId"])}/pledges`,
|
|
1878
|
+
project: (row) => ({
|
|
1879
|
+
...pick(row, ["amount_cents", "donated_total_cents", "joint_giver_amount_cents", "created_at"]),
|
|
1880
|
+
person_id: relId(row, "person") ?? null,
|
|
1881
|
+
}),
|
|
1882
|
+
}),
|
|
1883
|
+
list({
|
|
1884
|
+
name: "list_recurring_donations",
|
|
1885
|
+
description: "List Giving recurring donations with schedule, status, next occurrence, and fund designations. Read-only; amounts in cents.",
|
|
1886
|
+
key: "recurringDonations",
|
|
1887
|
+
item: record({
|
|
1888
|
+
id: STRING,
|
|
1889
|
+
amount_cents: INTEGER,
|
|
1890
|
+
amount_currency: STRING,
|
|
1891
|
+
schedule: NULLABLE_STRING,
|
|
1892
|
+
status: STRING,
|
|
1893
|
+
next_occurrence: NULLABLE_STRING,
|
|
1894
|
+
last_donation_received_at: NULLABLE_STRING,
|
|
1895
|
+
person_id: NULLABLE_STRING,
|
|
1896
|
+
designations: DESIGNATIONS,
|
|
1897
|
+
}),
|
|
1898
|
+
properties: { status: { type: "string", minLength: 1, description: "Status as Planning Center reports it, e.g. active." } },
|
|
1899
|
+
path: () => "/giving/v2/recurring_donations",
|
|
1900
|
+
query: (args) => ({ "where[status]": args["status"] }),
|
|
1901
|
+
include: "designations,designations.fund",
|
|
1902
|
+
project: (row, index) => ({
|
|
1903
|
+
...pick(row, ["amount_cents", "amount_currency", "schedule", "status", "next_occurrence", "last_donation_received_at"]),
|
|
1904
|
+
person_id: relId(row, "person") ?? null,
|
|
1905
|
+
designations: designations(row, index),
|
|
1906
|
+
}),
|
|
1907
|
+
}),
|
|
1908
|
+
// --- Webhooks -----------------------------------------------------------
|
|
1909
|
+
{
|
|
1910
|
+
name: "list_webhook_subscriptions",
|
|
1911
|
+
description: "List the organization's webhook subscriptions: event name, URL, and active state. Never returns the signing secret; that needs pco_api_get.",
|
|
1912
|
+
annotations: READ,
|
|
1913
|
+
inputSchema: input({ perPage: perPageProperty(defaultPageSize), offset: OFFSET_PROPERTY }),
|
|
1914
|
+
outputSchema: listOutput("subscriptions", record({ id: STRING, name: STRING, url: STRING, active: BOOLEAN, application_id: NULLABLE_STRING, created_at: STRING })),
|
|
1915
|
+
handler: async (args, ctx) => {
|
|
1916
|
+
const document = asRecord(await call({ method: "GET", path: "/webhooks/v2/webhook_subscriptions", query: { per_page: perPage(args), offset: args["offset"] } }, ctx));
|
|
1917
|
+
// `authenticity_secret` verifies deliveries. A projection is the
|
|
1918
|
+
// place it never appears, so this read has no `raw` mode.
|
|
1919
|
+
return {
|
|
1920
|
+
subscriptions: resources(document["data"]).map((row) => pick(row, ["name", "url", "active", "application_id", "created_at"])),
|
|
1921
|
+
page: pageOf(document),
|
|
1922
|
+
};
|
|
1923
|
+
},
|
|
1924
|
+
},
|
|
1925
|
+
];
|
|
1926
|
+
}
|
|
1927
|
+
// ---------------------------------------------------------------------------
|
|
1928
|
+
// Guide and construction
|
|
1929
|
+
// ---------------------------------------------------------------------------
|
|
1930
|
+
function usageGuide(purpose, instructions) {
|
|
1931
|
+
const organizationInstructions = instructions?.trim();
|
|
1932
|
+
return `# Planning Center usage
|
|
1933
|
+
|
|
1934
|
+
Planning Center Online church management — People, Services, Groups, Check-Ins, Calendar, Registrations, Giving, Publishing, Webhooks — for: ${purpose}
|
|
1935
|
+
|
|
1936
|
+
## Identity first
|
|
1937
|
+
|
|
1938
|
+
- A person id is the join key across every product: the id \`search_people\` returns is the \`personId\` for Services schedules, Groups memberships, Giving donations, and Check-Ins. Resolve ids with list tools; never guess one.
|
|
1939
|
+
- A person id that used to resolve and now answers \`not_found\` was most likely merged. Read \`/people/v2/person_mergers\` with \`where[person_to_remove_id]\` and follow \`person_to_keep_id\`.
|
|
1940
|
+
- \`get_me\` names the user this token acts as. Every read and write runs with that user's permissions, product by product; a \`connector_call_failed\` naming permission means the user cannot, not that the call was wrong.
|
|
1941
|
+
|
|
1942
|
+
## Results and paging
|
|
1943
|
+
|
|
1944
|
+
- Records keep Planning Center's own snake_case attribute names; a relationship appears as \`<relationship>_id\`. Pass \`raw: true\` for the untouched JSON:API resources and \`included\`.
|
|
1945
|
+
- Lists page by offset: \`perPage\` up to 100, then pass \`page.nextOffset\` back as \`offset\` while \`page.hasMore\` is true. \`page.totalCount\` answers "how many" without paging — request \`perPage: 1\`.
|
|
1946
|
+
- A bare date in a date filter is the organization's local day; pass a full timestamp for exact bounds. Name filters are exact; end one with \`%\` for a prefix match.
|
|
1947
|
+
- \`get_plan\` fetches the plan, its items, and its team in one call and sets \`itemsTruncated\`/\`teamMembersTruncated\` past 100 rows; page the rest with \`pco_api_get\`.
|
|
1948
|
+
|
|
1949
|
+
## What each product lets the API write
|
|
1950
|
+
|
|
1951
|
+
- People: full read/write. \`update_person\` with \`status: inactive\` archives reversibly; a DELETE is permanent and only reachable through \`pco_api_mutate\`. \`run_list\` can fire list automations.
|
|
1952
|
+
- Services: full read/write. A plan's dates come from its plan times. Scheduling needs a team.
|
|
1953
|
+
- Groups: memberships and group settings are writable; groups cannot be created, and events and attendance are read-only.
|
|
1954
|
+
- Check-Ins and Registrations: read-only. Nobody can be checked in or registered through the API.
|
|
1955
|
+
- Calendar: events, instances, and times are read-only; tags, resources, rooms, and folders are writable through \`pco_api_mutate\`.
|
|
1956
|
+
- Giving: the named tools only read, amounts in integer cents. Creating or editing donations, refunds, and committing a batch move money and go through \`pco_api_mutate\`.
|
|
1957
|
+
- Publishing and the \`api\` product (organization, OAuth applications, personal access tokens) have no named tools; use the hatches.
|
|
1958
|
+
|
|
1959
|
+
## The hatches
|
|
1960
|
+
|
|
1961
|
+
- \`pco_api_get\` reaches any GET; \`pco_api_mutate\` any POST, PATCH, or DELETE, always approval-gated. Paths start \`/<app>/v2\`, exactly as Planning Center documents them; the connector owns host, auth, content type, and the \`X-PCO-API-Version\` header.
|
|
1962
|
+
- Bodies are JSON:API documents: \`{"data":{"type":"Email","attributes":{...}}}\`. \`null\` clears an attribute; an omitted key is left alone.
|
|
1963
|
+
- Each product is pinned to a reviewed API version: ${PLANNING_CENTER_APPS.map((app) => `${app} ${PLANNING_CENTER_API_VERSIONS[app]}`).join(", ")}. Pass \`version\` only for an endpoint those versions lack.
|
|
1964
|
+
- Query parameters are name/value pairs with literal brackets: \`where[status]\`, \`include\`, \`order\`, \`filter\`, \`fields[Person]\`. Every list response's \`meta\` names the \`can_query_by\`, \`can_order_by\`, and \`can_include\` values for that endpoint; an unknown \`where\` key is ignored rather than refused.
|
|
1965
|
+
- File uploads go to a separate host (upload.planningcenteronline.com), which this connection does not reach.
|
|
1966
|
+
|
|
1967
|
+
## Rate limits
|
|
1968
|
+
|
|
1969
|
+
Planning Center allows 100 requests per 20 seconds per user, shared by every integration running as that user. This connection admits 100 calls per 20 seconds, five at a time, per runtime — an approximation, not a guarantee. A \`rate_limited\` failure carries Planning Center's wait. Filter server-side and use \`totalCount\` rather than paging to count.
|
|
1970
|
+
${organizationInstructions
|
|
1971
|
+
? `\n## Organization instructions\n\n${organizationInstructions}\n`
|
|
1972
|
+
: ""}`;
|
|
1973
|
+
}
|
|
1974
|
+
/** A maintained Planning Center Online connection over the REST API. */
|
|
1975
|
+
export function planningCenter(id, options) {
|
|
1976
|
+
const purpose = options.purpose.trim();
|
|
1977
|
+
if (!purpose) {
|
|
1978
|
+
throw new Error("planningCenter() requires a non-empty organization purpose.");
|
|
1979
|
+
}
|
|
1980
|
+
const defaultPageSize = options.defaultPageSize ?? DEFAULT_PAGE_SIZE;
|
|
1981
|
+
if (!Number.isInteger(defaultPageSize) || defaultPageSize < 1 || defaultPageSize > MAX_PAGE_SIZE) {
|
|
1982
|
+
throw new Error(`planningCenter() defaultPageSize must be a whole number between 1 and ${MAX_PAGE_SIZE}.`);
|
|
1983
|
+
}
|
|
1984
|
+
const userAgent = options.userAgent?.trim() ?? DEFAULT_USER_AGENT;
|
|
1985
|
+
if (!userAgent || /[\r\n]/.test(userAgent)) {
|
|
1986
|
+
throw new Error("planningCenter() userAgent must be a non-empty single line.");
|
|
1987
|
+
}
|
|
1988
|
+
const auth = PERSONAL_ACCESS_TOKEN;
|
|
1989
|
+
const send = guardedFetch({
|
|
1990
|
+
provider: "Planning Center",
|
|
1991
|
+
baseUrl: options.baseUrl?.trim() || PLANNING_CENTER_API_BASE_URL,
|
|
1992
|
+
headers: { Accept: "application/json", "User-Agent": userAgent },
|
|
1993
|
+
maxResponseBytes: PLANNING_CENTER_MAX_RESPONSE_BYTES,
|
|
1994
|
+
authenticate: (ctx) => auth.headers(ctx),
|
|
1995
|
+
});
|
|
1996
|
+
const call = planningCenterCall(send, PLANNING_CENTER_API_VERSIONS);
|
|
1997
|
+
return api(id, {
|
|
1998
|
+
...(options.authScope ? { authScope: options.authScope } : {}),
|
|
1999
|
+
title: options.title ?? "Planning Center",
|
|
2000
|
+
description: `Planning Center church management — People, Services, Groups, Check-Ins, Calendar, Registrations, and Giving: ${purpose}`,
|
|
2001
|
+
credential: auth.credential,
|
|
2002
|
+
async testCredentials(values, ctx) {
|
|
2003
|
+
try {
|
|
2004
|
+
const document = asRecord(await call({ method: "GET", path: "/people/v2/me", query: { include: "organization" } }, {
|
|
2005
|
+
...ctx,
|
|
2006
|
+
credential: {
|
|
2007
|
+
get: async (field) => (field ? values[field] ?? null : null),
|
|
2008
|
+
getAll: async () => values,
|
|
2009
|
+
},
|
|
2010
|
+
}));
|
|
2011
|
+
const me = created(document, "person record");
|
|
2012
|
+
const organization = resources(document["included"]).find((row) => row.type === "Organization");
|
|
2013
|
+
const name = attribute(me, "name") ?? `person ${me.id}`;
|
|
2014
|
+
const where = attribute(organization, "name");
|
|
2015
|
+
return {
|
|
2016
|
+
ok: true,
|
|
2017
|
+
message: `Authenticated as ${String(name)} (person ${me.id})${where ? ` in ${String(where)}` : ""}.`,
|
|
2018
|
+
};
|
|
2019
|
+
}
|
|
2020
|
+
catch (error) {
|
|
2021
|
+
return {
|
|
2022
|
+
ok: false,
|
|
2023
|
+
message: error instanceof ConnectorCallError ? error.message : "Planning Center rejected the token.",
|
|
2024
|
+
};
|
|
2025
|
+
}
|
|
2026
|
+
},
|
|
2027
|
+
callAdmission: options.callAdmission ?? PLANNING_CENTER_ADMISSION,
|
|
2028
|
+
usageGuide: {
|
|
2029
|
+
content: usageGuide(purpose, options.instructions),
|
|
2030
|
+
summary: "Person ids join every product, offset paging, per-product write limits, JSON:API hatches, pinned versions.",
|
|
2031
|
+
// Deliberately not `required`. Every named tool's schema is complete
|
|
2032
|
+
// enough to call it on its own; the guide carries the cross-product
|
|
2033
|
+
// facts (merges, write limits, hatch framing) an agent needs only when
|
|
2034
|
+
// it leaves the named surface or hits one of them.
|
|
2035
|
+
},
|
|
2036
|
+
...(options.maxResultBytes !== undefined ? { maxResultBytes: options.maxResultBytes } : {}),
|
|
2037
|
+
tools: tools(call, defaultPageSize),
|
|
2038
|
+
});
|
|
2039
|
+
}
|