@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,1402 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Breeze ChMS — rebranded "Tithely Church Management" on 2025-11-11 — as a
|
|
3
|
+
* hand-written `api()` surface. No SDK exists worth wrapping: Breeze's API is
|
|
4
|
+
* authenticated `GET` with query parameters against one church's own host, so
|
|
5
|
+
* Web APIs alone keep this provider Workers-clean and every import relative.
|
|
6
|
+
*
|
|
7
|
+
* What makes this provider unlike the others is that **every Breeze call is a
|
|
8
|
+
* GET, writes included** — `people/add`, `people/update?fields_json=…`,
|
|
9
|
+
* `tags/assign`, and `giving/delete` all travel as query strings. The HTTP
|
|
10
|
+
* method therefore says nothing about safety, and the escape-hatch split
|
|
11
|
+
* cannot be made by method the way Cloudflare's and Vercel's is. It is made by
|
|
12
|
+
* *endpoint*:
|
|
13
|
+
*
|
|
14
|
+
* - `breeze_api_get` is read-only and admits only a reviewed allowlist of read
|
|
15
|
+
* endpoints, each with its own allowlist of query parameter names. Anything
|
|
16
|
+
* unlisted is refused locally before a request exists. A denylist of write
|
|
17
|
+
* verbs (add/update/delete/remove/…) was rejected: it fails open on the next
|
|
18
|
+
* verb Breeze invents (`destroy`, `assign`, `unassign`, and `edit` are
|
|
19
|
+
* already four that a naive list misses), and `/people/{id}` would sit one
|
|
20
|
+
* typo away from `/people/delete`. The parameter allowlist closes the
|
|
21
|
+
* remaining hole — a PHP front controller that routes on a query parameter,
|
|
22
|
+
* or a read endpoint with an undocumented side-effecting flag — at the cost
|
|
23
|
+
* of refusing undocumented read parameters, which the mutate hatch still
|
|
24
|
+
* reaches with approval.
|
|
25
|
+
* - `breeze_api_mutate` reaches every endpoint, read or write, documented or
|
|
26
|
+
* not, and is always destructive. That is where "full coverage" lives.
|
|
27
|
+
*
|
|
28
|
+
* Giving (`giving/*`, `funds/*`, `pledges/*`) left Breeze's public reference
|
|
29
|
+
* around September 2023 but is still served: without a key those routes answer
|
|
30
|
+
* 403 "API Key required" while an unknown controller answers 404 (checked
|
|
31
|
+
* 2026-10-05 — note the 404 only proves the *controller* is unknown; an
|
|
32
|
+
* unknown action under a live controller also answers 403, so no action can
|
|
33
|
+
* be proven live without a key). They are shipped as named reads anyway,
|
|
34
|
+
* because they are the practical read path for church giving: Tithe.ly 2.0
|
|
35
|
+
* syncs processed gifts into Breeze contributions nightly, per deposit, so
|
|
36
|
+
* Breeze is where an agent finds giving next to the people it belongs to.
|
|
37
|
+
* The parameters come from Breeze's own reference as archived on 2023-06-07
|
|
38
|
+
* (web.archive.org/web/20230607234759/https://app.breezechms.com/api) and are
|
|
39
|
+
* corroborated by the pyBreezeChMS wrapper's `list_contributions` and
|
|
40
|
+
* `list_funds`. The guide tells agents they are undocumented and may change.
|
|
41
|
+
* Giving *writes* (`giving/add`, `giving/edit`, `giving/delete`) alter a
|
|
42
|
+
* church's financial records and stay on the destructive mutate hatch.
|
|
43
|
+
*
|
|
44
|
+
* Drift is review-only. Breeze publishes no machine-readable specification —
|
|
45
|
+
* the reference at https://app.breezechms.com/api is hand-written HTML whose
|
|
46
|
+
* argument tables sometimes contradict their own examples (giving dates are
|
|
47
|
+
* "DD-MM-YYYY" in the table and `2022-1-15` in the example request) — so there
|
|
48
|
+
* is nothing for `scripts/drift-check.mjs` to digest honestly. A release
|
|
49
|
+
* reviewer re-reads that page against `READ_ENDPOINTS` below and the named
|
|
50
|
+
* tools' paths; the documented endpoint set reviewed for this release is the
|
|
51
|
+
* 42 endpoint rows of the page's table of contents as of 2026-10-05 (41
|
|
52
|
+
* distinct paths: the Tags section's "List People" points back at /people)
|
|
53
|
+
* plus the 2023 giving, funds, and pledges sections.
|
|
54
|
+
*/
|
|
55
|
+
import { apiConnector as api, defined } from "../connectors/api-connector.js";
|
|
56
|
+
import { guardedFetch, retryAfterMs, } from "../connectors/guarded-fetch.js";
|
|
57
|
+
import { ConnectorCallError } from "../errors.js";
|
|
58
|
+
/** Every church's API lives at `https://<subdomain>.breezechms.com/api`. */
|
|
59
|
+
export const BREEZE_HOST_SUFFIX = ".breezechms.com";
|
|
60
|
+
const DEFAULT_PAGE_SIZE = 100;
|
|
61
|
+
const MAX_PAGE_SIZE = 1_000;
|
|
62
|
+
const DEFAULT_EVENT_LIMIT = 100;
|
|
63
|
+
const MAX_EVENT_LIMIT = 1_000;
|
|
64
|
+
const DEFAULT_LOG_LIMIT = 100;
|
|
65
|
+
const MAX_LOG_LIMIT = 3_000;
|
|
66
|
+
/**
|
|
67
|
+
* A people list with details can be large; the read hatch can ask Breeze for
|
|
68
|
+
* every person at once. A ceiling on absurdity, not a quota.
|
|
69
|
+
*/
|
|
70
|
+
const BREEZE_MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
|
|
71
|
+
/** One DNS label: what `<subdomain>.breezechms.com` can actually be. */
|
|
72
|
+
const SUBDOMAIN_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
73
|
+
function asRecord(value) {
|
|
74
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
75
|
+
? value
|
|
76
|
+
: {};
|
|
77
|
+
}
|
|
78
|
+
function asArray(value) {
|
|
79
|
+
return Array.isArray(value) ? value : [];
|
|
80
|
+
}
|
|
81
|
+
function compact(value) {
|
|
82
|
+
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
|
|
83
|
+
}
|
|
84
|
+
/** Breeze writes "no time" as MySQL's zero date; a projection says null. */
|
|
85
|
+
function timestamp(value) {
|
|
86
|
+
if (value === undefined)
|
|
87
|
+
return undefined;
|
|
88
|
+
if (typeof value !== "string" || value === "" || value.startsWith("0000-00-00")) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
return value;
|
|
92
|
+
}
|
|
93
|
+
function flag(value) {
|
|
94
|
+
if (value === undefined || value === null)
|
|
95
|
+
return undefined;
|
|
96
|
+
return value === true || value === 1 || value === "1";
|
|
97
|
+
}
|
|
98
|
+
function idString(value) {
|
|
99
|
+
return value === undefined || value === null || value === ""
|
|
100
|
+
? undefined
|
|
101
|
+
: String(value);
|
|
102
|
+
}
|
|
103
|
+
// --- Transport and failures ---------------------------------------------------
|
|
104
|
+
/**
|
|
105
|
+
* The one place the credential becomes headers. Breeze takes a per-account key
|
|
106
|
+
* in `Api-Key`; a future OAuth-issued bearer would replace this function and
|
|
107
|
+
* nothing else, because every request already reaches it through
|
|
108
|
+
* `guardedFetch`'s `authenticate` hook.
|
|
109
|
+
*/
|
|
110
|
+
async function apiKeyHeaders(ctx) {
|
|
111
|
+
const key = (await ctx.credential?.get())?.trim();
|
|
112
|
+
if (!key) {
|
|
113
|
+
throw new ConnectorCallError("auth_required", "No Breeze API key is configured for this connector. Call authorize_connector for recovery options. When available, an operator can add the key in this connection in the operator UI.");
|
|
114
|
+
}
|
|
115
|
+
return { "Api-Key": key };
|
|
116
|
+
}
|
|
117
|
+
function breezeTransport(subdomain) {
|
|
118
|
+
return guardedFetch({
|
|
119
|
+
provider: "Breeze",
|
|
120
|
+
baseUrl: `https://${subdomain}${BREEZE_HOST_SUFFIX}/api`,
|
|
121
|
+
headers: { Accept: "application/json" },
|
|
122
|
+
maxResponseBytes: BREEZE_MAX_RESPONSE_BYTES,
|
|
123
|
+
authenticate: apiKeyHeaders,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Breeze's failure bodies are short plain text ("Invalid API Key") or an HTML
|
|
128
|
+
* page. Quote the former, never the latter.
|
|
129
|
+
*/
|
|
130
|
+
function bodyDetail(text) {
|
|
131
|
+
const trimmed = text.trim();
|
|
132
|
+
if (!trimmed || trimmed.startsWith("<") || trimmed.length > 200)
|
|
133
|
+
return "";
|
|
134
|
+
return ` Breeze said: ${trimmed}`;
|
|
135
|
+
}
|
|
136
|
+
/** Map a non-2xx Breeze response by the caller's next move. */
|
|
137
|
+
function breezeFailure(status, headers, text) {
|
|
138
|
+
const detail = bodyDetail(text);
|
|
139
|
+
if (status === 429) {
|
|
140
|
+
const wait = retryAfterMs(headers);
|
|
141
|
+
return new ConnectorCallError("rate_limited", `Breeze is throttling requests (HTTP 429).${detail} Breeze publishes no limit; slow the fan-out before retrying.`, wait === undefined ? {} : { retryAfterMs: wait });
|
|
142
|
+
}
|
|
143
|
+
// Breeze keys are account-wide with no scopes, so a 401/403 is the key
|
|
144
|
+
// itself — missing, revoked, or from another church — never a permission gap
|
|
145
|
+
// a different argument could avoid.
|
|
146
|
+
if (status === 401 || status === 403) {
|
|
147
|
+
return new ConnectorCallError("auth_required", `Breeze rejected the configured API key (HTTP ${status}).${detail} An operator must replace it with the key from this church's Extensions → API page.`);
|
|
148
|
+
}
|
|
149
|
+
// With an account-wide key there is no permission gap to hide behind a 404:
|
|
150
|
+
// it is an absent endpoint (a mistyped hatch path) or record.
|
|
151
|
+
if (status === 404) {
|
|
152
|
+
return new ConnectorCallError("not_found", `Breeze has no such endpoint or record (HTTP 404). Check the path against the API reference, or re-read the id from its list tool.`);
|
|
153
|
+
}
|
|
154
|
+
if (status === 400 || status === 422) {
|
|
155
|
+
return new ConnectorCallError("invalid_args", `Breeze rejected the request (HTTP ${status}).${detail}`);
|
|
156
|
+
}
|
|
157
|
+
if (status >= 500) {
|
|
158
|
+
const wait = retryAfterMs(headers);
|
|
159
|
+
return new ConnectorCallError("unavailable", `Breeze is failing upstream (HTTP ${status}).`, wait === undefined ? {} : { retryAfterMs: wait });
|
|
160
|
+
}
|
|
161
|
+
return new ConnectorCallError("connector_call_failed", `Breeze request failed (HTTP ${status}).${detail}`, { retryable: false });
|
|
162
|
+
}
|
|
163
|
+
function shortText(value) {
|
|
164
|
+
if (typeof value === "string")
|
|
165
|
+
return value.trim() || undefined;
|
|
166
|
+
if (value === undefined || value === null)
|
|
167
|
+
return undefined;
|
|
168
|
+
const text = JSON.stringify(value);
|
|
169
|
+
return text.length > 300 ? `${text.slice(0, 300)}…` : text;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Breeze can report a failure inside a 200: `{ success: false, errors: … }`
|
|
173
|
+
* or an `errorCode`. The giving endpoints' success shape is
|
|
174
|
+
* `{ success: true, errors: null }`, so a null or empty `errors` is not one.
|
|
175
|
+
*/
|
|
176
|
+
function reportedFailure(payload) {
|
|
177
|
+
const root = asRecord(payload);
|
|
178
|
+
const errors = root["errors"];
|
|
179
|
+
const hasErrors = errors !== undefined &&
|
|
180
|
+
errors !== null &&
|
|
181
|
+
errors !== false &&
|
|
182
|
+
errors !== "" &&
|
|
183
|
+
!(Array.isArray(errors) && errors.length === 0) &&
|
|
184
|
+
!(typeof errors === "object" && !Array.isArray(errors) && Object.keys(errors).length === 0);
|
|
185
|
+
if (root["success"] !== false && root["errorCode"] === undefined && !hasErrors) {
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
const message = shortText(root["errorMessage"]) ??
|
|
189
|
+
shortText(root["message"]) ??
|
|
190
|
+
shortText(errors) ??
|
|
191
|
+
shortText(root["errorCode"]) ??
|
|
192
|
+
"no detail";
|
|
193
|
+
// Breeze uses the same envelope for a rejected argument and a missing
|
|
194
|
+
// record, so the code stays the honest generic one and the message says so.
|
|
195
|
+
return new ConnectorCallError("connector_call_failed", `Breeze reported a failure: ${message}. Breeze does not say whether an argument or a record is at fault; re-check ids with the list tools before retrying.`, { retryable: false });
|
|
196
|
+
}
|
|
197
|
+
async function callBreeze(send, request, ctx, options = {}) {
|
|
198
|
+
return await send(request, ctx, async (response) => {
|
|
199
|
+
const text = await response.text();
|
|
200
|
+
if (!response.ok) {
|
|
201
|
+
throw breezeFailure(response.status, response.headers, text);
|
|
202
|
+
}
|
|
203
|
+
if (!text.trim())
|
|
204
|
+
return null;
|
|
205
|
+
let payload;
|
|
206
|
+
try {
|
|
207
|
+
payload = JSON.parse(text);
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
// Breeze labels JSON text/html, so the content type proves nothing;
|
|
211
|
+
// only the parse does. A hatch hands back what arrived.
|
|
212
|
+
if (options.raw)
|
|
213
|
+
return text;
|
|
214
|
+
throw new ConnectorCallError("connector_call_failed", "Breeze returned a non-JSON successful response.", { retryable: false });
|
|
215
|
+
}
|
|
216
|
+
const failure = reportedFailure(payload);
|
|
217
|
+
if (failure)
|
|
218
|
+
throw failure;
|
|
219
|
+
return payload;
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
/** A named write whose response is a bare boolean reports `false` on failure. */
|
|
223
|
+
function requireTrue(payload, action) {
|
|
224
|
+
if (payload === false) {
|
|
225
|
+
throw new ConnectorCallError("connector_call_failed", `Breeze answered false to ${action}. Breeze gives no reason; confirm the ids with their list tools before retrying.`, { retryable: false });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
// --- The read hatch's allowlist ----------------------------------------------
|
|
229
|
+
/**
|
|
230
|
+
* Every read endpoint `breeze_api_get` may reach, with the query parameter
|
|
231
|
+
* names each one documents. Paths are relative to `/api`, lowercase, exact;
|
|
232
|
+
* `/people/{id}` is the one templated row and accepts digits only, so it can
|
|
233
|
+
* never spell `/people/add` or `/people/delete`. Rows are added here only
|
|
234
|
+
* after a reviewer has read the endpoint as observational.
|
|
235
|
+
*/
|
|
236
|
+
const READ_ENDPOINTS = new Map([
|
|
237
|
+
["/people", ["details", "filter_json", "limit", "offset"]],
|
|
238
|
+
["/people/{id}", ["details"]],
|
|
239
|
+
["/profile", []],
|
|
240
|
+
["/tags/list_tags", ["folder_id"]],
|
|
241
|
+
["/tags/list_folders", []],
|
|
242
|
+
["/events", ["start", "end", "category_id", "calendar_id", "eligible", "details", "limit"]],
|
|
243
|
+
["/events/list_event", ["instance_id", "schedule", "schedule_direction", "schedule_limit", "eligible", "details"]],
|
|
244
|
+
["/events/calendars/list", []],
|
|
245
|
+
["/events/locations", []],
|
|
246
|
+
["/events/attendance/list", ["instance_id", "details", "type"]],
|
|
247
|
+
["/events/attendance/eligible", ["instance_id"]],
|
|
248
|
+
["/forms/list_forms", ["is_archived"]],
|
|
249
|
+
["/forms/list_form_fields", ["form_id"]],
|
|
250
|
+
["/forms/list_form_entries", ["form_id", "details"]],
|
|
251
|
+
["/volunteers/list", ["instance_id"]],
|
|
252
|
+
["/volunteers/list_roles", ["instance_id", "show_quantity"]],
|
|
253
|
+
["/account/summary", []],
|
|
254
|
+
["/account/list_log", ["action", "start", "end", "user_id", "details", "limit"]],
|
|
255
|
+
// Undocumented since ~2023-09; parameters from the 2023-06-07 reference.
|
|
256
|
+
["/giving/list", ["start", "end", "person_id", "include_family", "amount_min", "amount_max", "method_ids", "fund_ids", "envelope_number", "batches", "forms", "pledge_ids"]],
|
|
257
|
+
["/giving/view", ["payment_id"]],
|
|
258
|
+
["/funds/list", ["include_totals"]],
|
|
259
|
+
["/pledges/list_campaigns", []],
|
|
260
|
+
["/pledges/list_pledges", ["campaign_id"]],
|
|
261
|
+
]);
|
|
262
|
+
/** A Breeze path: lowercase segments of letters, digits, and underscores. */
|
|
263
|
+
const PATH_PATTERN = /^(\/[a-z0-9_]+)+\/?$/;
|
|
264
|
+
function normalizedPath(raw) {
|
|
265
|
+
const path = typeof raw === "string" ? raw : "";
|
|
266
|
+
if (!PATH_PATTERN.test(path)) {
|
|
267
|
+
throw new ConnectorCallError("invalid_args", "A Breeze path is relative to /api, begins with '/', and uses only lowercase letters, digits, underscores, and '/' — for example /tags/list_folders. No query string, host, or dot segments.");
|
|
268
|
+
}
|
|
269
|
+
return path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path;
|
|
270
|
+
}
|
|
271
|
+
function readEndpointFor(path) {
|
|
272
|
+
const template = /^\/people\/\d+$/.test(path) ? "/people/{id}" : path;
|
|
273
|
+
const parameters = READ_ENDPOINTS.get(template);
|
|
274
|
+
if (!parameters) {
|
|
275
|
+
throw new ConnectorCallError("invalid_args", `breeze_api_get reaches only reviewed read endpoints, and ${path} is not one. Breeze sends writes as GET too, so the read hatch is an allowlist: ${[...READ_ENDPOINTS.keys()].join(", ")}. Use breeze_api_mutate for anything else.`);
|
|
276
|
+
}
|
|
277
|
+
return parameters;
|
|
278
|
+
}
|
|
279
|
+
function queryPairs(value) {
|
|
280
|
+
// Null-prototype, so a parameter named __proto__ or constructor is a key
|
|
281
|
+
// like any other rather than a reach into Object.prototype.
|
|
282
|
+
const query = Object.create(null);
|
|
283
|
+
for (const row of asArray(value)) {
|
|
284
|
+
const pair = asRecord(row);
|
|
285
|
+
const name = pair["name"];
|
|
286
|
+
if (typeof name !== "string")
|
|
287
|
+
continue;
|
|
288
|
+
if (name in query) {
|
|
289
|
+
throw new ConnectorCallError("invalid_args", `Query parameter ${name} appears more than once; Breeze reads one value per name.`);
|
|
290
|
+
}
|
|
291
|
+
const item = pair["value"];
|
|
292
|
+
query[name] =
|
|
293
|
+
item !== null && typeof item === "object" ? JSON.stringify(item) : item;
|
|
294
|
+
}
|
|
295
|
+
return query;
|
|
296
|
+
}
|
|
297
|
+
// --- Projections -------------------------------------------------------------
|
|
298
|
+
function projectPersonSummary(value) {
|
|
299
|
+
const person = asRecord(value);
|
|
300
|
+
return compact({
|
|
301
|
+
id: idString(person["id"]),
|
|
302
|
+
firstName: person["first_name"],
|
|
303
|
+
forceFirstName: person["force_first_name"],
|
|
304
|
+
lastName: person["last_name"],
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
function projectFamilyMember(value) {
|
|
308
|
+
const member = asRecord(value);
|
|
309
|
+
const details = asRecord(member["details"]);
|
|
310
|
+
return compact({
|
|
311
|
+
personId: idString(member["person_id"] ?? details["id"]),
|
|
312
|
+
familyId: idString(member["family_id"]),
|
|
313
|
+
role: member["role_name"],
|
|
314
|
+
firstName: details["first_name"],
|
|
315
|
+
lastName: details["last_name"],
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
/** Drops photo paths; keeps the profile-field `details` map Breeze returned. */
|
|
319
|
+
function projectPerson(value) {
|
|
320
|
+
const person = asRecord(value);
|
|
321
|
+
const family = asArray(person["family"]);
|
|
322
|
+
const details = person["details"];
|
|
323
|
+
return compact({
|
|
324
|
+
...projectPersonSummary(person),
|
|
325
|
+
nickName: person["nick_name"] || undefined,
|
|
326
|
+
middleName: person["middle_name"] || undefined,
|
|
327
|
+
maidenName: person["maiden_name"] || undefined,
|
|
328
|
+
details: details && typeof details === "object" && !Array.isArray(details)
|
|
329
|
+
? details
|
|
330
|
+
: undefined,
|
|
331
|
+
family: family.length ? family.map(projectFamilyMember) : undefined,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
function projectProfileFields(value) {
|
|
335
|
+
const fields = [];
|
|
336
|
+
for (const sectionValue of asArray(value)) {
|
|
337
|
+
const section = asRecord(sectionValue);
|
|
338
|
+
for (const fieldValue of asArray(section["fields"])) {
|
|
339
|
+
const field = asRecord(fieldValue);
|
|
340
|
+
const options = asArray(field["options"]).map((optionValue) => {
|
|
341
|
+
const option = asRecord(optionValue);
|
|
342
|
+
return compact({
|
|
343
|
+
optionId: idString(option["option_id"] ?? option["id"]),
|
|
344
|
+
name: option["name"],
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
fields.push(compact({
|
|
348
|
+
fieldId: idString(field["field_id"]),
|
|
349
|
+
name: field["name"],
|
|
350
|
+
type: field["field_type"],
|
|
351
|
+
section: section["name"],
|
|
352
|
+
options: options.length ? options : undefined,
|
|
353
|
+
}));
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return fields;
|
|
357
|
+
}
|
|
358
|
+
function projectEvent(value) {
|
|
359
|
+
const event = asRecord(value);
|
|
360
|
+
return compact({
|
|
361
|
+
id: idString(event["id"]),
|
|
362
|
+
eventId: idString(event["event_id"]),
|
|
363
|
+
name: event["name"],
|
|
364
|
+
calendarId: idString(event["category_id"]),
|
|
365
|
+
startsAt: timestamp(event["start_datetime"]),
|
|
366
|
+
endsAt: timestamp(event["end_datetime"]),
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
function projectContribution(value) {
|
|
370
|
+
const payment = asRecord(value);
|
|
371
|
+
const paidOn = payment["paid_on"];
|
|
372
|
+
return compact({
|
|
373
|
+
id: idString(payment["id"]),
|
|
374
|
+
paidOn: typeof paidOn === "string" ? paidOn.slice(0, 10) : undefined,
|
|
375
|
+
amount: idString(payment["amount"]),
|
|
376
|
+
method: payment["method"],
|
|
377
|
+
methodId: idString(payment["method_id"]),
|
|
378
|
+
personId: idString(payment["person_id"]),
|
|
379
|
+
firstName: payment["first_name"],
|
|
380
|
+
lastName: payment["last_name"],
|
|
381
|
+
envelopeNumber: idString(payment["envelope_number"]),
|
|
382
|
+
batchNumber: idString(payment["num"] ?? payment["batch_num"]),
|
|
383
|
+
note: payment["note"] || undefined,
|
|
384
|
+
funds: asArray(payment["funds"]).map((fundValue) => {
|
|
385
|
+
const fund = asRecord(fundValue);
|
|
386
|
+
return compact({
|
|
387
|
+
fundId: idString(fund["fund_id"]),
|
|
388
|
+
name: fund["fund_name"],
|
|
389
|
+
amount: idString(fund["amount"]),
|
|
390
|
+
taxDeductible: flag(fund["tax_deductible"]),
|
|
391
|
+
});
|
|
392
|
+
}),
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Sum decimal strings in integer cents, so a giving total never inherits
|
|
397
|
+
* floating-point error. Null when any amount is not a plain decimal.
|
|
398
|
+
*/
|
|
399
|
+
function totalAmount(amounts) {
|
|
400
|
+
let cents = 0;
|
|
401
|
+
for (const amount of amounts) {
|
|
402
|
+
const match = /^(-)?(\d+)(?:\.(\d{1,2}))?$/.exec(String(amount ?? "").trim());
|
|
403
|
+
if (!match)
|
|
404
|
+
return null;
|
|
405
|
+
const value = Number(match[2]) * 100 + Number((match[3] ?? "").padEnd(2, "0"));
|
|
406
|
+
cents += match[1] ? -value : value;
|
|
407
|
+
}
|
|
408
|
+
const sign = cents < 0 ? "-" : "";
|
|
409
|
+
const absolute = Math.abs(cents);
|
|
410
|
+
return `${sign}${Math.trunc(absolute / 100)}.${String(absolute % 100).padStart(2, "0")}`;
|
|
411
|
+
}
|
|
412
|
+
function parsedObject(value) {
|
|
413
|
+
if (typeof value !== "string")
|
|
414
|
+
return value;
|
|
415
|
+
try {
|
|
416
|
+
return JSON.parse(value);
|
|
417
|
+
}
|
|
418
|
+
catch {
|
|
419
|
+
return value;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
// --- Schemas -----------------------------------------------------------------
|
|
423
|
+
function namedInput(properties, required = []) {
|
|
424
|
+
return { type: "object", properties, required, additionalProperties: false };
|
|
425
|
+
}
|
|
426
|
+
const RAW_PROPERTY = {
|
|
427
|
+
type: "boolean",
|
|
428
|
+
description: "Return Breeze's untouched rows instead of the lean projection.",
|
|
429
|
+
};
|
|
430
|
+
const ID_PATTERN = "^[0-9]+$";
|
|
431
|
+
function idProperty(description) {
|
|
432
|
+
return { type: "string", pattern: ID_PATTERN, maxLength: 20, description };
|
|
433
|
+
}
|
|
434
|
+
const PERSON_ID = idProperty("Breeze person id from list_people.");
|
|
435
|
+
const INSTANCE_ID = idProperty("Event instance id (list_events id, not eventId).");
|
|
436
|
+
const FORM_ID = idProperty("Form id from list_forms.");
|
|
437
|
+
const DATE_PATTERN = "^[0-9]{4}-[0-9]{2}-[0-9]{2}$";
|
|
438
|
+
function dateProperty(description) {
|
|
439
|
+
return { type: "string", pattern: DATE_PATTERN, description };
|
|
440
|
+
}
|
|
441
|
+
const ID_LIST = {
|
|
442
|
+
type: "array",
|
|
443
|
+
minItems: 1,
|
|
444
|
+
maxItems: 50,
|
|
445
|
+
items: { type: "string", pattern: ID_PATTERN, description: "Numeric id." },
|
|
446
|
+
};
|
|
447
|
+
const QUERY_PROPERTY = {
|
|
448
|
+
type: "array",
|
|
449
|
+
maxItems: 30,
|
|
450
|
+
description: "Query parameters as name/value pairs.",
|
|
451
|
+
items: {
|
|
452
|
+
type: "object",
|
|
453
|
+
properties: {
|
|
454
|
+
name: { type: "string", minLength: 1, maxLength: 64, description: "Parameter name, e.g. fields_json." },
|
|
455
|
+
value: {
|
|
456
|
+
type: ["string", "number", "boolean", "object", "array"],
|
|
457
|
+
description: "Value; objects and arrays are JSON-encoded for *_json parameters.",
|
|
458
|
+
},
|
|
459
|
+
},
|
|
460
|
+
required: ["name", "value"],
|
|
461
|
+
additionalProperties: false,
|
|
462
|
+
},
|
|
463
|
+
};
|
|
464
|
+
const PATH_PROPERTY = {
|
|
465
|
+
type: "string",
|
|
466
|
+
minLength: 2,
|
|
467
|
+
maxLength: 100,
|
|
468
|
+
description: "Path below /api, e.g. /tags/list_folders. No query string.",
|
|
469
|
+
};
|
|
470
|
+
const RESULT_SCHEMA = {
|
|
471
|
+
type: "object",
|
|
472
|
+
properties: { result: { description: "Breeze's untouched response." } },
|
|
473
|
+
required: ["result"],
|
|
474
|
+
};
|
|
475
|
+
const PERSON_SUMMARY = {
|
|
476
|
+
id: { type: "string" },
|
|
477
|
+
firstName: { type: "string" },
|
|
478
|
+
forceFirstName: { type: "string", description: "Breeze's display first name." },
|
|
479
|
+
lastName: { type: "string" },
|
|
480
|
+
};
|
|
481
|
+
const PERSON_SCHEMA = {
|
|
482
|
+
type: "object",
|
|
483
|
+
properties: {
|
|
484
|
+
...PERSON_SUMMARY,
|
|
485
|
+
nickName: { type: "string" },
|
|
486
|
+
middleName: { type: "string" },
|
|
487
|
+
maidenName: { type: "string" },
|
|
488
|
+
details: { type: "object", description: "Values keyed by profile fieldId." },
|
|
489
|
+
family: { type: "array", items: { type: "object" } },
|
|
490
|
+
},
|
|
491
|
+
required: ["id"],
|
|
492
|
+
};
|
|
493
|
+
const PAGE_SCHEMA = {
|
|
494
|
+
type: "object",
|
|
495
|
+
properties: {
|
|
496
|
+
hasMore: { type: "boolean", description: "True when the page came back full." },
|
|
497
|
+
nextCursor: { type: ["string", "null"] },
|
|
498
|
+
},
|
|
499
|
+
required: ["hasMore", "nextCursor"],
|
|
500
|
+
};
|
|
501
|
+
const TRUNCATED_PROPERTY = {
|
|
502
|
+
type: "boolean",
|
|
503
|
+
description: "True when the result filled limit; narrow the range.",
|
|
504
|
+
};
|
|
505
|
+
const WROTE_SCHEMA = (key) => ({
|
|
506
|
+
type: "object",
|
|
507
|
+
properties: { [key]: { type: "boolean" } },
|
|
508
|
+
required: [key],
|
|
509
|
+
});
|
|
510
|
+
const PROFILE_FIELD_TYPES = [
|
|
511
|
+
"text",
|
|
512
|
+
"textarea",
|
|
513
|
+
"radio",
|
|
514
|
+
"checkbox",
|
|
515
|
+
"date",
|
|
516
|
+
"birthdate",
|
|
517
|
+
"email",
|
|
518
|
+
"phone",
|
|
519
|
+
"address",
|
|
520
|
+
"family_role",
|
|
521
|
+
];
|
|
522
|
+
/** Field types whose value rides in `details`, with `response: true`. */
|
|
523
|
+
const DETAIL_FIELD_TYPES = new Set(["email", "phone", "address", "family_role"]);
|
|
524
|
+
const FIELDS_PROPERTY = {
|
|
525
|
+
type: "array",
|
|
526
|
+
minItems: 1,
|
|
527
|
+
maxItems: 50,
|
|
528
|
+
description: "Profile values to set. fieldId and option ids come from list_profile_fields.",
|
|
529
|
+
items: {
|
|
530
|
+
type: "object",
|
|
531
|
+
properties: {
|
|
532
|
+
fieldId: { type: "string", pattern: ID_PATTERN, description: "Profile fieldId." },
|
|
533
|
+
type: {
|
|
534
|
+
type: "string",
|
|
535
|
+
enum: [...PROFILE_FIELD_TYPES],
|
|
536
|
+
description: "radio covers multiple choice and dropdown.",
|
|
537
|
+
},
|
|
538
|
+
response: {
|
|
539
|
+
type: ["string", "boolean"],
|
|
540
|
+
description: "Text, M/D/YYYY date, or option id; true for detail types.",
|
|
541
|
+
},
|
|
542
|
+
details: {
|
|
543
|
+
type: "object",
|
|
544
|
+
description: "email {address}; phone {phone_mobile|phone_home|phone_work}; address {street_address,city,state,zip}; family_role {person_id,role_id 1-5}.",
|
|
545
|
+
},
|
|
546
|
+
},
|
|
547
|
+
required: ["fieldId", "type"],
|
|
548
|
+
additionalProperties: false,
|
|
549
|
+
},
|
|
550
|
+
};
|
|
551
|
+
function fieldsJson(value) {
|
|
552
|
+
const fields = asArray(value).map((row) => {
|
|
553
|
+
const field = asRecord(row);
|
|
554
|
+
const type = String(field["type"]);
|
|
555
|
+
const needsDetails = DETAIL_FIELD_TYPES.has(type);
|
|
556
|
+
if (needsDetails && !field["details"]) {
|
|
557
|
+
throw new ConnectorCallError("invalid_args", `A ${type} field carries its value in details; field ${String(field["fieldId"])} has none.`);
|
|
558
|
+
}
|
|
559
|
+
if (!needsDetails && field["response"] === undefined) {
|
|
560
|
+
throw new ConnectorCallError("invalid_args", `A ${type} field needs a response; field ${String(field["fieldId"])} has none.`);
|
|
561
|
+
}
|
|
562
|
+
return compact({
|
|
563
|
+
field_id: field["fieldId"],
|
|
564
|
+
field_type: type,
|
|
565
|
+
response: field["response"] ?? (needsDetails ? true : undefined),
|
|
566
|
+
details: field["details"],
|
|
567
|
+
});
|
|
568
|
+
});
|
|
569
|
+
return JSON.stringify(fields);
|
|
570
|
+
}
|
|
571
|
+
// --- Tools -------------------------------------------------------------------
|
|
572
|
+
function tools(send, defaultPageSize) {
|
|
573
|
+
const readOnly = { readOnlyHint: true };
|
|
574
|
+
const additive = { readOnlyHint: false };
|
|
575
|
+
const destructive = { readOnlyHint: false, destructiveHint: true };
|
|
576
|
+
const get = (path, query, ctx, options) => callBreeze(send, { method: "GET", path, query }, ctx, options);
|
|
577
|
+
return [
|
|
578
|
+
{
|
|
579
|
+
name: "breeze_api_get",
|
|
580
|
+
description: "Call a reviewed Breeze read endpoint and return its untouched response. Refuses every path and parameter not on the read allowlist, since Breeze writes are GETs too.",
|
|
581
|
+
annotations: readOnly,
|
|
582
|
+
inputSchema: namedInput({ path: PATH_PROPERTY, query: QUERY_PROPERTY }, ["path"]),
|
|
583
|
+
outputSchema: RESULT_SCHEMA,
|
|
584
|
+
handler: async (args, ctx) => {
|
|
585
|
+
const path = normalizedPath(args["path"]);
|
|
586
|
+
const allowed = readEndpointFor(path);
|
|
587
|
+
const query = queryPairs(args["query"]);
|
|
588
|
+
const stray = Object.keys(query).filter((name) => !allowed.includes(name));
|
|
589
|
+
if (stray.length) {
|
|
590
|
+
throw new ConnectorCallError("invalid_args", `breeze_api_get refuses ${stray.join(", ")} on ${path}; its reviewed parameters are ${allowed.join(", ") || "none"}. Use breeze_api_mutate for anything else.`);
|
|
591
|
+
}
|
|
592
|
+
return { result: await get(path, query, ctx, { raw: true }) };
|
|
593
|
+
},
|
|
594
|
+
},
|
|
595
|
+
{
|
|
596
|
+
name: "breeze_api_mutate",
|
|
597
|
+
description: "Call any Breeze API endpoint, including writes and undocumented routes, and return its untouched response. The approval-gated hatch; Breeze takes writes as GET query parameters.",
|
|
598
|
+
annotations: destructive,
|
|
599
|
+
inputSchema: namedInput({ path: PATH_PROPERTY, query: QUERY_PROPERTY }, ["path"]),
|
|
600
|
+
outputSchema: RESULT_SCHEMA,
|
|
601
|
+
handler: async (args, ctx) => ({
|
|
602
|
+
result: await get(normalizedPath(args["path"]), queryPairs(args["query"]), ctx, { raw: true }),
|
|
603
|
+
}),
|
|
604
|
+
},
|
|
605
|
+
{
|
|
606
|
+
name: "get_account_summary",
|
|
607
|
+
description: "Get the Breeze account's church name, subdomain, timezone, country, and currency. Use it to read giving amounts in the right currency.",
|
|
608
|
+
annotations: readOnly,
|
|
609
|
+
inputSchema: namedInput({}),
|
|
610
|
+
outputSchema: {
|
|
611
|
+
type: "object",
|
|
612
|
+
properties: {
|
|
613
|
+
id: { type: "string" }, name: { type: "string" }, subdomain: { type: "string" },
|
|
614
|
+
timezone: { type: "string" }, country: { type: "string" }, currency: { type: "string" },
|
|
615
|
+
},
|
|
616
|
+
required: ["name"],
|
|
617
|
+
},
|
|
618
|
+
handler: async (_args, ctx) => {
|
|
619
|
+
const account = asRecord(await get("/account/summary", {}, ctx));
|
|
620
|
+
const details = asRecord(account["details"]);
|
|
621
|
+
const country = asRecord(details["country"]);
|
|
622
|
+
return compact({
|
|
623
|
+
id: idString(account["id"]),
|
|
624
|
+
name: account["name"],
|
|
625
|
+
subdomain: account["subdomain"],
|
|
626
|
+
timezone: details["timezone"],
|
|
627
|
+
country: country["name"],
|
|
628
|
+
currency: country["currency"],
|
|
629
|
+
});
|
|
630
|
+
},
|
|
631
|
+
},
|
|
632
|
+
{
|
|
633
|
+
name: "list_people",
|
|
634
|
+
description: "List people in Breeze, optionally filtered by tag or profile-field values, with id and names or full profile details. No name search; filter names in code.",
|
|
635
|
+
annotations: readOnly,
|
|
636
|
+
inputSchema: namedInput({
|
|
637
|
+
tagId: idProperty("Only people carrying this tag id from list_tags."),
|
|
638
|
+
filter: {
|
|
639
|
+
type: "object",
|
|
640
|
+
description: "Breeze filter_json: profile fieldId to option ids joined by '-', e.g. {\"2000138015\":\"226-227\"}.",
|
|
641
|
+
},
|
|
642
|
+
includeDetails: { type: "boolean", description: "Include every profile field (slower, larger). Defaults to false." },
|
|
643
|
+
limit: {
|
|
644
|
+
type: "integer", minimum: 1, maximum: MAX_PAGE_SIZE,
|
|
645
|
+
description: `People per page, 1 to connecta's ${MAX_PAGE_SIZE}. Defaults to ${defaultPageSize}.`,
|
|
646
|
+
},
|
|
647
|
+
cursor: { type: "string", pattern: ID_PATTERN, description: "Opaque nextCursor from the previous page." },
|
|
648
|
+
raw: RAW_PROPERTY,
|
|
649
|
+
}),
|
|
650
|
+
outputSchema: {
|
|
651
|
+
type: "object",
|
|
652
|
+
properties: { people: { type: "array", items: PERSON_SCHEMA }, page: PAGE_SCHEMA },
|
|
653
|
+
required: ["people", "page"],
|
|
654
|
+
},
|
|
655
|
+
handler: async (args, ctx) => {
|
|
656
|
+
const filter = { ...asRecord(args["filter"]) };
|
|
657
|
+
if (args["tagId"] !== undefined) {
|
|
658
|
+
if (filter["tag_contains"] !== undefined) {
|
|
659
|
+
throw new ConnectorCallError("invalid_args", "Pass tagId or filter.tag_contains, not both.");
|
|
660
|
+
}
|
|
661
|
+
filter["tag_contains"] = `y_${args["tagId"]}`;
|
|
662
|
+
}
|
|
663
|
+
const limit = args["limit"] ?? defaultPageSize;
|
|
664
|
+
const offset = args["cursor"] === undefined ? 0 : Number(args["cursor"]);
|
|
665
|
+
const rows = asArray(await get("/people", {
|
|
666
|
+
details: args["includeDetails"] === true ? 1 : 0,
|
|
667
|
+
filter_json: Object.keys(filter).length ? JSON.stringify(filter) : undefined,
|
|
668
|
+
limit,
|
|
669
|
+
offset,
|
|
670
|
+
}, ctx));
|
|
671
|
+
// Breeze returns no total, so a full page is the only "maybe more"
|
|
672
|
+
// signal it gives; the page after an exact multiple comes back empty.
|
|
673
|
+
const hasMore = rows.length >= limit;
|
|
674
|
+
return {
|
|
675
|
+
people: args["raw"] === true ? rows : rows.map(projectPerson),
|
|
676
|
+
page: { hasMore, nextCursor: hasMore ? String(offset + rows.length) : null },
|
|
677
|
+
};
|
|
678
|
+
},
|
|
679
|
+
},
|
|
680
|
+
{
|
|
681
|
+
name: "get_person",
|
|
682
|
+
description: "Get one person by Breeze id with profile details keyed by fieldId and family members. Decode fieldIds with list_profile_fields.",
|
|
683
|
+
annotations: readOnly,
|
|
684
|
+
inputSchema: namedInput({
|
|
685
|
+
personId: PERSON_ID,
|
|
686
|
+
includeDetails: { type: "boolean", description: "Include profile details. Defaults to true." },
|
|
687
|
+
raw: RAW_PROPERTY,
|
|
688
|
+
}, ["personId"]),
|
|
689
|
+
outputSchema: PERSON_SCHEMA,
|
|
690
|
+
handler: async (args, ctx) => {
|
|
691
|
+
const payload = await get(`/people/${args["personId"]}`, {
|
|
692
|
+
details: args["includeDetails"] === false ? 0 : 1,
|
|
693
|
+
}, ctx);
|
|
694
|
+
// An account-wide key cannot be refused a person, so an empty answer
|
|
695
|
+
// is an absence rather than a permission gap.
|
|
696
|
+
if (!asRecord(payload)["id"]) {
|
|
697
|
+
throw new ConnectorCallError("not_found", `Breeze has no person ${args["personId"]}. Re-read the id from list_people.`);
|
|
698
|
+
}
|
|
699
|
+
return args["raw"] === true ? payload : projectPerson(payload);
|
|
700
|
+
},
|
|
701
|
+
},
|
|
702
|
+
{
|
|
703
|
+
name: "list_profile_fields",
|
|
704
|
+
description: "List profile fields with fieldId, type, section, and option ids. Decodes person details and builds list_people filters and update_person values.",
|
|
705
|
+
annotations: readOnly,
|
|
706
|
+
inputSchema: namedInput({ raw: RAW_PROPERTY }),
|
|
707
|
+
outputSchema: {
|
|
708
|
+
type: "object",
|
|
709
|
+
properties: {
|
|
710
|
+
fields: {
|
|
711
|
+
type: "array",
|
|
712
|
+
items: {
|
|
713
|
+
type: "object",
|
|
714
|
+
properties: {
|
|
715
|
+
fieldId: { type: "string" }, name: { type: "string" }, type: { type: "string" },
|
|
716
|
+
section: { type: "string" }, options: { type: "array", items: { type: "object" } },
|
|
717
|
+
},
|
|
718
|
+
required: ["fieldId", "name", "type"],
|
|
719
|
+
},
|
|
720
|
+
},
|
|
721
|
+
},
|
|
722
|
+
required: ["fields"],
|
|
723
|
+
},
|
|
724
|
+
handler: async (args, ctx) => {
|
|
725
|
+
const payload = await get("/profile", {}, ctx);
|
|
726
|
+
return { fields: args["raw"] === true ? asArray(payload) : projectProfileFields(payload) };
|
|
727
|
+
},
|
|
728
|
+
},
|
|
729
|
+
{
|
|
730
|
+
name: "list_tags",
|
|
731
|
+
description: "List Breeze tags with id, name, and folder. Tags model groups and lists; pass a tag id to list_people or assign_tag.",
|
|
732
|
+
annotations: readOnly,
|
|
733
|
+
inputSchema: namedInput({
|
|
734
|
+
folderId: idProperty("Only tags in this folder. Folders come from breeze_api_get /tags/list_folders."),
|
|
735
|
+
}),
|
|
736
|
+
outputSchema: {
|
|
737
|
+
type: "object",
|
|
738
|
+
properties: {
|
|
739
|
+
tags: {
|
|
740
|
+
type: "array",
|
|
741
|
+
items: {
|
|
742
|
+
type: "object",
|
|
743
|
+
properties: { id: { type: "string" }, name: { type: "string" }, folderId: { type: "string" } },
|
|
744
|
+
required: ["id", "name"],
|
|
745
|
+
},
|
|
746
|
+
},
|
|
747
|
+
},
|
|
748
|
+
required: ["tags"],
|
|
749
|
+
},
|
|
750
|
+
handler: async (args, ctx) => ({
|
|
751
|
+
tags: asArray(await get("/tags/list_tags", { folder_id: args["folderId"] }, ctx)).map((value) => {
|
|
752
|
+
const tag = asRecord(value);
|
|
753
|
+
return compact({ id: idString(tag["id"]), name: tag["name"], folderId: idString(tag["folder_id"]) });
|
|
754
|
+
}),
|
|
755
|
+
}),
|
|
756
|
+
},
|
|
757
|
+
{
|
|
758
|
+
name: "list_events",
|
|
759
|
+
description: "List event instances between two dates with instance id, series id, calendar, and times. Breeze caches this list for up to 15 minutes.",
|
|
760
|
+
annotations: readOnly,
|
|
761
|
+
inputSchema: namedInput({
|
|
762
|
+
start: dateProperty("First day, YYYY-MM-DD. Defaults to the first of this month."),
|
|
763
|
+
end: dateProperty("Last day, YYYY-MM-DD. Defaults to the end of this month."),
|
|
764
|
+
calendarId: idProperty("Only events on this calendar from list_calendars."),
|
|
765
|
+
limit: {
|
|
766
|
+
type: "integer", minimum: 1, maximum: MAX_EVENT_LIMIT,
|
|
767
|
+
description: `Instances returned, 1 to Breeze's ${MAX_EVENT_LIMIT}. Defaults to ${DEFAULT_EVENT_LIMIT}.`,
|
|
768
|
+
},
|
|
769
|
+
raw: RAW_PROPERTY,
|
|
770
|
+
}),
|
|
771
|
+
outputSchema: {
|
|
772
|
+
type: "object",
|
|
773
|
+
properties: {
|
|
774
|
+
events: {
|
|
775
|
+
type: "array",
|
|
776
|
+
items: {
|
|
777
|
+
type: "object",
|
|
778
|
+
properties: {
|
|
779
|
+
id: { type: "string", description: "Instance id." }, eventId: { type: "string", description: "Series id." },
|
|
780
|
+
name: { type: "string" }, calendarId: { type: "string" },
|
|
781
|
+
startsAt: { type: ["string", "null"] }, endsAt: { type: ["string", "null"] },
|
|
782
|
+
},
|
|
783
|
+
required: ["id", "name"],
|
|
784
|
+
},
|
|
785
|
+
},
|
|
786
|
+
truncated: TRUNCATED_PROPERTY,
|
|
787
|
+
},
|
|
788
|
+
required: ["events", "truncated"],
|
|
789
|
+
},
|
|
790
|
+
handler: async (args, ctx) => {
|
|
791
|
+
const limit = args["limit"] ?? DEFAULT_EVENT_LIMIT;
|
|
792
|
+
const rows = asArray(await get("/events", {
|
|
793
|
+
start: args["start"], end: args["end"], category_id: args["calendarId"], limit,
|
|
794
|
+
}, ctx));
|
|
795
|
+
return {
|
|
796
|
+
events: args["raw"] === true ? rows : rows.map(projectEvent),
|
|
797
|
+
truncated: rows.length >= limit,
|
|
798
|
+
};
|
|
799
|
+
},
|
|
800
|
+
},
|
|
801
|
+
{
|
|
802
|
+
name: "list_calendars",
|
|
803
|
+
description: "List Breeze event calendars with id, name, and color. Omits each calendar's private iCal feed address, which works without a key.",
|
|
804
|
+
annotations: readOnly,
|
|
805
|
+
inputSchema: namedInput({}),
|
|
806
|
+
outputSchema: {
|
|
807
|
+
type: "object",
|
|
808
|
+
properties: {
|
|
809
|
+
calendars: {
|
|
810
|
+
type: "array",
|
|
811
|
+
items: {
|
|
812
|
+
type: "object",
|
|
813
|
+
properties: { id: { type: "string" }, name: { type: "string" }, color: { type: "string" } },
|
|
814
|
+
required: ["id", "name"],
|
|
815
|
+
},
|
|
816
|
+
},
|
|
817
|
+
},
|
|
818
|
+
required: ["calendars"],
|
|
819
|
+
},
|
|
820
|
+
handler: async (_args, ctx) => ({
|
|
821
|
+
calendars: asArray(await get("/events/calendars/list", {}, ctx)).map((value) => {
|
|
822
|
+
const calendar = asRecord(value);
|
|
823
|
+
return compact({ id: idString(calendar["id"]) ?? "0", name: calendar["name"], color: calendar["color"] });
|
|
824
|
+
}),
|
|
825
|
+
}),
|
|
826
|
+
},
|
|
827
|
+
{
|
|
828
|
+
name: "list_attendance",
|
|
829
|
+
description: "List people checked in to one event instance, with check-in and check-out times. Not anonymous head counts; those come from breeze_api_get.",
|
|
830
|
+
annotations: readOnly,
|
|
831
|
+
inputSchema: namedInput({
|
|
832
|
+
instanceId: INSTANCE_ID,
|
|
833
|
+
includeNames: { type: "boolean", description: "Add each attendee's name. Contact details need raw." },
|
|
834
|
+
raw: RAW_PROPERTY,
|
|
835
|
+
}, ["instanceId"]),
|
|
836
|
+
outputSchema: {
|
|
837
|
+
type: "object",
|
|
838
|
+
properties: {
|
|
839
|
+
attendance: {
|
|
840
|
+
type: "array",
|
|
841
|
+
items: {
|
|
842
|
+
type: "object",
|
|
843
|
+
properties: {
|
|
844
|
+
personId: { type: "string" }, firstName: { type: "string" }, lastName: { type: "string" },
|
|
845
|
+
checkedInAt: { type: ["string", "null"] }, checkedOutAt: { type: ["string", "null"] },
|
|
846
|
+
},
|
|
847
|
+
required: ["personId"],
|
|
848
|
+
},
|
|
849
|
+
},
|
|
850
|
+
},
|
|
851
|
+
required: ["attendance"],
|
|
852
|
+
},
|
|
853
|
+
handler: async (args, ctx) => {
|
|
854
|
+
const wantDetails = args["includeNames"] === true || args["raw"] === true;
|
|
855
|
+
const rows = asArray(await get("/events/attendance/list", {
|
|
856
|
+
instance_id: args["instanceId"], type: "person", details: wantDetails ? "true" : "false",
|
|
857
|
+
}, ctx));
|
|
858
|
+
if (args["raw"] === true)
|
|
859
|
+
return { attendance: rows };
|
|
860
|
+
return {
|
|
861
|
+
attendance: rows.map((value) => {
|
|
862
|
+
const record = asRecord(value);
|
|
863
|
+
const person = asRecord(record["details"]);
|
|
864
|
+
return compact({
|
|
865
|
+
personId: idString(record["person_id"]),
|
|
866
|
+
firstName: person["first_name"],
|
|
867
|
+
lastName: person["last_name"],
|
|
868
|
+
checkedInAt: timestamp(record["created_on"]),
|
|
869
|
+
checkedOutAt: timestamp(record["check_out"]),
|
|
870
|
+
});
|
|
871
|
+
}),
|
|
872
|
+
};
|
|
873
|
+
},
|
|
874
|
+
},
|
|
875
|
+
{
|
|
876
|
+
name: "list_forms",
|
|
877
|
+
description: "List Breeze forms with id, name, and URL slug. Active forms by default; archived ones on request.",
|
|
878
|
+
annotations: readOnly,
|
|
879
|
+
inputSchema: namedInput({
|
|
880
|
+
archived: { type: "boolean", description: "List archived forms instead of active ones." },
|
|
881
|
+
}),
|
|
882
|
+
outputSchema: {
|
|
883
|
+
type: "object",
|
|
884
|
+
properties: {
|
|
885
|
+
forms: {
|
|
886
|
+
type: "array",
|
|
887
|
+
items: {
|
|
888
|
+
type: "object",
|
|
889
|
+
properties: {
|
|
890
|
+
id: { type: "string" }, name: { type: "string" }, slug: { type: "string" },
|
|
891
|
+
archived: { type: "boolean" }, createdAt: { type: "string" },
|
|
892
|
+
},
|
|
893
|
+
required: ["id", "name"],
|
|
894
|
+
},
|
|
895
|
+
},
|
|
896
|
+
},
|
|
897
|
+
required: ["forms"],
|
|
898
|
+
},
|
|
899
|
+
handler: async (args, ctx) => ({
|
|
900
|
+
forms: asArray(await get("/forms/list_forms", { is_archived: args["archived"] === true ? 1 : 0 }, ctx)).map((value) => {
|
|
901
|
+
const form = asRecord(value);
|
|
902
|
+
return compact({
|
|
903
|
+
id: idString(form["id"]), name: form["name"], slug: form["url_slug"],
|
|
904
|
+
archived: flag(form["is_archived"]), createdAt: form["created_on"],
|
|
905
|
+
});
|
|
906
|
+
}),
|
|
907
|
+
}),
|
|
908
|
+
},
|
|
909
|
+
{
|
|
910
|
+
name: "list_form_fields",
|
|
911
|
+
description: "List one form's fields with fieldId, name, type, and options. Decodes the responses list_form_entries returns.",
|
|
912
|
+
annotations: readOnly,
|
|
913
|
+
inputSchema: namedInput({ formId: FORM_ID }, ["formId"]),
|
|
914
|
+
outputSchema: {
|
|
915
|
+
type: "object",
|
|
916
|
+
properties: {
|
|
917
|
+
fields: {
|
|
918
|
+
type: "array",
|
|
919
|
+
items: {
|
|
920
|
+
type: "object",
|
|
921
|
+
properties: {
|
|
922
|
+
fieldId: { type: "string" }, name: { type: "string" }, type: { type: "string" },
|
|
923
|
+
options: { type: "array", items: { type: "object" } },
|
|
924
|
+
},
|
|
925
|
+
required: ["fieldId", "name"],
|
|
926
|
+
},
|
|
927
|
+
},
|
|
928
|
+
},
|
|
929
|
+
required: ["fields"],
|
|
930
|
+
},
|
|
931
|
+
handler: async (args, ctx) => ({
|
|
932
|
+
// Form fields share the profile-field row shape, minus a section.
|
|
933
|
+
fields: projectProfileFields([
|
|
934
|
+
{ fields: await get("/forms/list_form_fields", { form_id: args["formId"] }, ctx) },
|
|
935
|
+
]),
|
|
936
|
+
}),
|
|
937
|
+
},
|
|
938
|
+
{
|
|
939
|
+
name: "list_form_entries",
|
|
940
|
+
description: "List one form's entries with entry id, person id, and submission time; responses keyed by form fieldId on request. Not paginated.",
|
|
941
|
+
annotations: readOnly,
|
|
942
|
+
inputSchema: namedInput({
|
|
943
|
+
formId: FORM_ID,
|
|
944
|
+
includeResponses: { type: "boolean", description: "Include each entry's answers keyed by fieldId." },
|
|
945
|
+
}, ["formId"]),
|
|
946
|
+
outputSchema: {
|
|
947
|
+
type: "object",
|
|
948
|
+
properties: {
|
|
949
|
+
entries: {
|
|
950
|
+
type: "array",
|
|
951
|
+
items: {
|
|
952
|
+
type: "object",
|
|
953
|
+
properties: {
|
|
954
|
+
id: { type: "string" }, personId: { type: ["string", "null"] },
|
|
955
|
+
createdAt: { type: "string" }, responses: { type: "object" },
|
|
956
|
+
},
|
|
957
|
+
required: ["id"],
|
|
958
|
+
},
|
|
959
|
+
},
|
|
960
|
+
},
|
|
961
|
+
required: ["entries"],
|
|
962
|
+
},
|
|
963
|
+
handler: async (args, ctx) => ({
|
|
964
|
+
entries: asArray(await get("/forms/list_form_entries", {
|
|
965
|
+
form_id: args["formId"], details: args["includeResponses"] === true ? 1 : 0,
|
|
966
|
+
}, ctx)).map((value) => {
|
|
967
|
+
const entry = asRecord(value);
|
|
968
|
+
return compact({
|
|
969
|
+
id: idString(entry["id"]),
|
|
970
|
+
personId: idString(entry["person_id"]) ?? null,
|
|
971
|
+
createdAt: entry["created_on"],
|
|
972
|
+
responses: args["includeResponses"] === true ? entry["response"] : undefined,
|
|
973
|
+
});
|
|
974
|
+
}),
|
|
975
|
+
}),
|
|
976
|
+
},
|
|
977
|
+
{
|
|
978
|
+
name: "list_volunteers",
|
|
979
|
+
description: "List people scheduled to volunteer at one event instance, with their role ids and RSVP state.",
|
|
980
|
+
annotations: readOnly,
|
|
981
|
+
inputSchema: namedInput({ instanceId: INSTANCE_ID }, ["instanceId"]),
|
|
982
|
+
outputSchema: {
|
|
983
|
+
type: "object",
|
|
984
|
+
properties: {
|
|
985
|
+
volunteers: {
|
|
986
|
+
type: "array",
|
|
987
|
+
items: {
|
|
988
|
+
type: "object",
|
|
989
|
+
properties: {
|
|
990
|
+
personId: { type: "string" }, roleIds: { type: "array", items: { type: "string" } },
|
|
991
|
+
response: { type: "string" }, comment: { type: "string" }, rsvpedAt: { type: ["string", "null"] },
|
|
992
|
+
},
|
|
993
|
+
required: ["personId", "roleIds"],
|
|
994
|
+
},
|
|
995
|
+
},
|
|
996
|
+
},
|
|
997
|
+
required: ["volunteers"],
|
|
998
|
+
},
|
|
999
|
+
handler: async (args, ctx) => ({
|
|
1000
|
+
volunteers: asArray(await get("/volunteers/list", { instance_id: args["instanceId"] }, ctx)).map((value) => {
|
|
1001
|
+
const volunteer = asRecord(value);
|
|
1002
|
+
return compact({
|
|
1003
|
+
personId: idString(volunteer["person_id"]),
|
|
1004
|
+
roleIds: asArray(volunteer["role_ids"]).map(String),
|
|
1005
|
+
response: idString(volunteer["response"]),
|
|
1006
|
+
comment: volunteer["comment"] || undefined,
|
|
1007
|
+
rsvpedAt: timestamp(volunteer["rsvped_on"]),
|
|
1008
|
+
});
|
|
1009
|
+
}),
|
|
1010
|
+
}),
|
|
1011
|
+
},
|
|
1012
|
+
{
|
|
1013
|
+
name: "list_volunteer_roles",
|
|
1014
|
+
description: "List the volunteer roles defined on one event instance with id, name, and quantity needed.",
|
|
1015
|
+
annotations: readOnly,
|
|
1016
|
+
inputSchema: namedInput({ instanceId: INSTANCE_ID }, ["instanceId"]),
|
|
1017
|
+
outputSchema: {
|
|
1018
|
+
type: "object",
|
|
1019
|
+
properties: {
|
|
1020
|
+
roles: {
|
|
1021
|
+
type: "array",
|
|
1022
|
+
items: {
|
|
1023
|
+
type: "object",
|
|
1024
|
+
properties: { id: { type: "string" }, name: { type: "string" }, quantity: { type: "number" } },
|
|
1025
|
+
required: ["id", "name"],
|
|
1026
|
+
},
|
|
1027
|
+
},
|
|
1028
|
+
},
|
|
1029
|
+
required: ["roles"],
|
|
1030
|
+
},
|
|
1031
|
+
handler: async (args, ctx) => ({
|
|
1032
|
+
roles: asArray(await get("/volunteers/list_roles", { instance_id: args["instanceId"], show_quantity: 1 }, ctx)).map((value) => {
|
|
1033
|
+
const role = asRecord(value);
|
|
1034
|
+
const quantity = Number(role["quantity"]);
|
|
1035
|
+
return compact({
|
|
1036
|
+
id: idString(role["id"]), name: role["name"],
|
|
1037
|
+
quantity: Number.isFinite(quantity) ? quantity : undefined,
|
|
1038
|
+
});
|
|
1039
|
+
}),
|
|
1040
|
+
}),
|
|
1041
|
+
},
|
|
1042
|
+
{
|
|
1043
|
+
name: "list_account_log",
|
|
1044
|
+
description: "List logged account actions of one type, such as person_updated or contribution_added, with object ids and times. The way to find what changed since a date.",
|
|
1045
|
+
annotations: readOnly,
|
|
1046
|
+
inputSchema: namedInput({
|
|
1047
|
+
action: {
|
|
1048
|
+
type: "string", pattern: "^[a-z_]+$", maxLength: 40,
|
|
1049
|
+
description: "Logged action, e.g. person_updated, tag_assign, contribution_added; the guide lists all.",
|
|
1050
|
+
},
|
|
1051
|
+
start: dateProperty("Only actions on or after this day, YYYY-MM-DD."),
|
|
1052
|
+
end: dateProperty("Only actions on or before this day, YYYY-MM-DD."),
|
|
1053
|
+
userId: idProperty("Only actions by this Breeze user id."),
|
|
1054
|
+
includeDetails: { type: "boolean", description: "Include Breeze's unstandardized description." },
|
|
1055
|
+
limit: {
|
|
1056
|
+
type: "integer", minimum: 1, maximum: MAX_LOG_LIMIT,
|
|
1057
|
+
description: `Rows returned, 1 to Breeze's ${MAX_LOG_LIMIT}. Defaults to ${DEFAULT_LOG_LIMIT}.`,
|
|
1058
|
+
},
|
|
1059
|
+
}, ["action"]),
|
|
1060
|
+
outputSchema: {
|
|
1061
|
+
type: "object",
|
|
1062
|
+
properties: {
|
|
1063
|
+
entries: {
|
|
1064
|
+
type: "array",
|
|
1065
|
+
items: {
|
|
1066
|
+
type: "object",
|
|
1067
|
+
properties: {
|
|
1068
|
+
id: { type: "string" }, action: { type: "string" }, userId: { type: "string" },
|
|
1069
|
+
object: { description: "Parsed object_json, usually the affected id." },
|
|
1070
|
+
createdAt: { type: "string" }, details: {},
|
|
1071
|
+
},
|
|
1072
|
+
required: ["id", "action"],
|
|
1073
|
+
},
|
|
1074
|
+
},
|
|
1075
|
+
truncated: TRUNCATED_PROPERTY,
|
|
1076
|
+
},
|
|
1077
|
+
required: ["entries", "truncated"],
|
|
1078
|
+
},
|
|
1079
|
+
handler: async (args, ctx) => {
|
|
1080
|
+
const limit = args["limit"] ?? DEFAULT_LOG_LIMIT;
|
|
1081
|
+
const rows = asArray(await get("/account/list_log", {
|
|
1082
|
+
action: args["action"], start: args["start"], end: args["end"], user_id: args["userId"],
|
|
1083
|
+
details: args["includeDetails"] === true ? 1 : 0, limit,
|
|
1084
|
+
}, ctx));
|
|
1085
|
+
return {
|
|
1086
|
+
entries: rows.map((value) => {
|
|
1087
|
+
const entry = asRecord(value);
|
|
1088
|
+
return compact({
|
|
1089
|
+
id: idString(entry["id"]),
|
|
1090
|
+
action: entry["action"],
|
|
1091
|
+
userId: idString(entry["user_id"]),
|
|
1092
|
+
object: parsedObject(entry["object_json"]),
|
|
1093
|
+
createdAt: entry["created_on"],
|
|
1094
|
+
details: args["includeDetails"] === true ? entry["details"] : undefined,
|
|
1095
|
+
});
|
|
1096
|
+
}),
|
|
1097
|
+
truncated: rows.length >= limit,
|
|
1098
|
+
};
|
|
1099
|
+
},
|
|
1100
|
+
},
|
|
1101
|
+
{
|
|
1102
|
+
name: "list_contributions",
|
|
1103
|
+
description: "List giving transactions between two dates with donor, amount, method, batch, and fund split, plus a count and exact total. Undocumented by Breeze since 2023.",
|
|
1104
|
+
annotations: readOnly,
|
|
1105
|
+
inputSchema: namedInput({
|
|
1106
|
+
start: dateProperty("First gift date, YYYY-MM-DD."),
|
|
1107
|
+
end: dateProperty("Last gift date, YYYY-MM-DD."),
|
|
1108
|
+
personId: idProperty("Only this donor's gifts."),
|
|
1109
|
+
includeFamily: { type: "boolean", description: "With personId, include the donor's family." },
|
|
1110
|
+
amountMin: { type: "number", minimum: 0, description: "Gift amount at least this." },
|
|
1111
|
+
amountMax: { type: "number", minimum: 0, description: "Gift amount at most this." },
|
|
1112
|
+
fundIds: { ...ID_LIST, description: "Only gifts to these funds from list_funds." },
|
|
1113
|
+
methodIds: { ...ID_LIST, description: "Only these payment method ids." },
|
|
1114
|
+
batchNumbers: { ...ID_LIST, description: "Only these batch numbers." },
|
|
1115
|
+
envelopeNumber: { type: "string", maxLength: 20, description: "Only this envelope number." },
|
|
1116
|
+
raw: RAW_PROPERTY,
|
|
1117
|
+
}, ["start", "end"]),
|
|
1118
|
+
outputSchema: {
|
|
1119
|
+
type: "object",
|
|
1120
|
+
properties: {
|
|
1121
|
+
contributions: {
|
|
1122
|
+
type: "array",
|
|
1123
|
+
items: {
|
|
1124
|
+
type: "object",
|
|
1125
|
+
properties: {
|
|
1126
|
+
id: { type: "string" }, paidOn: { type: "string" }, amount: { type: "string" },
|
|
1127
|
+
method: { type: "string" }, personId: { type: "string" }, firstName: { type: "string" },
|
|
1128
|
+
lastName: { type: "string" }, batchNumber: { type: "string" }, funds: { type: "array", items: { type: "object" } },
|
|
1129
|
+
},
|
|
1130
|
+
required: ["id", "amount"],
|
|
1131
|
+
},
|
|
1132
|
+
},
|
|
1133
|
+
count: { type: "integer" },
|
|
1134
|
+
totalAmount: { type: ["string", "null"], description: "Exact decimal sum." },
|
|
1135
|
+
},
|
|
1136
|
+
required: ["contributions", "count", "totalAmount"],
|
|
1137
|
+
},
|
|
1138
|
+
handler: async (args, ctx) => {
|
|
1139
|
+
if (args["includeFamily"] === true && args["personId"] === undefined) {
|
|
1140
|
+
throw new ConnectorCallError("invalid_args", "includeFamily needs a personId.");
|
|
1141
|
+
}
|
|
1142
|
+
const joined = (value) => Array.isArray(value) ? value.join("-") : undefined;
|
|
1143
|
+
// Breeze's 2023 table says DD-MM-YYYY while its own example request
|
|
1144
|
+
// sends 2022-1-15; the server parses both, so the unambiguous ISO day
|
|
1145
|
+
// is what the schema accepts and what goes on the wire.
|
|
1146
|
+
const rows = asArray(await get("/giving/list", {
|
|
1147
|
+
start: args["start"], end: args["end"], person_id: args["personId"],
|
|
1148
|
+
include_family: args["includeFamily"] === true ? 1 : undefined,
|
|
1149
|
+
amount_min: args["amountMin"], amount_max: args["amountMax"],
|
|
1150
|
+
fund_ids: joined(args["fundIds"]), method_ids: joined(args["methodIds"]),
|
|
1151
|
+
batches: joined(args["batchNumbers"]), envelope_number: args["envelopeNumber"],
|
|
1152
|
+
}, ctx));
|
|
1153
|
+
return {
|
|
1154
|
+
contributions: args["raw"] === true ? rows : rows.map(projectContribution),
|
|
1155
|
+
count: rows.length,
|
|
1156
|
+
totalAmount: totalAmount(rows.map((row) => asRecord(row)["amount"])),
|
|
1157
|
+
};
|
|
1158
|
+
},
|
|
1159
|
+
},
|
|
1160
|
+
{
|
|
1161
|
+
name: "list_funds",
|
|
1162
|
+
description: "List giving funds with id, name, tax-deductible flag, and default flag. Supplies fundIds for list_contributions. Undocumented by Breeze since 2023.",
|
|
1163
|
+
annotations: readOnly,
|
|
1164
|
+
inputSchema: namedInput({}),
|
|
1165
|
+
outputSchema: {
|
|
1166
|
+
type: "object",
|
|
1167
|
+
properties: {
|
|
1168
|
+
funds: {
|
|
1169
|
+
type: "array",
|
|
1170
|
+
items: {
|
|
1171
|
+
type: "object",
|
|
1172
|
+
properties: {
|
|
1173
|
+
id: { type: "string" }, name: { type: "string" },
|
|
1174
|
+
taxDeductible: { type: "boolean" }, isDefault: { type: "boolean" },
|
|
1175
|
+
},
|
|
1176
|
+
required: ["id", "name"],
|
|
1177
|
+
},
|
|
1178
|
+
},
|
|
1179
|
+
},
|
|
1180
|
+
required: ["funds"],
|
|
1181
|
+
},
|
|
1182
|
+
handler: async (_args, ctx) => ({
|
|
1183
|
+
funds: asArray(await get("/funds/list", {}, ctx)).map((value) => {
|
|
1184
|
+
const fund = asRecord(value);
|
|
1185
|
+
return compact({
|
|
1186
|
+
id: idString(fund["id"]), name: fund["name"],
|
|
1187
|
+
taxDeductible: flag(fund["tax_deductible"]), isDefault: flag(fund["is_default"]),
|
|
1188
|
+
});
|
|
1189
|
+
}),
|
|
1190
|
+
}),
|
|
1191
|
+
},
|
|
1192
|
+
{
|
|
1193
|
+
name: "add_person",
|
|
1194
|
+
description: "Create a new person profile with a first and last name and optional profile values. Does not check for duplicates; search list_people first.",
|
|
1195
|
+
annotations: additive,
|
|
1196
|
+
inputSchema: namedInput({
|
|
1197
|
+
firstName: { type: "string", minLength: 1, maxLength: 100, description: "First name." },
|
|
1198
|
+
lastName: { type: "string", minLength: 1, maxLength: 100, description: "Last name." },
|
|
1199
|
+
fields: FIELDS_PROPERTY,
|
|
1200
|
+
}, ["firstName", "lastName"]),
|
|
1201
|
+
outputSchema: PERSON_SCHEMA,
|
|
1202
|
+
handler: async (args, ctx) => {
|
|
1203
|
+
const payload = await get("/people/add", {
|
|
1204
|
+
first: args["firstName"], last: args["lastName"],
|
|
1205
|
+
fields_json: args["fields"] === undefined ? undefined : fieldsJson(args["fields"]),
|
|
1206
|
+
}, ctx);
|
|
1207
|
+
const person = Array.isArray(payload) ? payload[0] : payload;
|
|
1208
|
+
if (!asRecord(person)["id"]) {
|
|
1209
|
+
throw new ConnectorCallError("connector_call_failed", "Breeze accepted the new person without returning it; check list_people before retrying so it is not added twice.", { retryable: false });
|
|
1210
|
+
}
|
|
1211
|
+
return projectPerson(person);
|
|
1212
|
+
},
|
|
1213
|
+
},
|
|
1214
|
+
{
|
|
1215
|
+
name: "update_person",
|
|
1216
|
+
description: "Set profile values on one person: contact details, multiple choice, dates, text, or family role. Replaces each named field's value.",
|
|
1217
|
+
annotations: destructive,
|
|
1218
|
+
inputSchema: namedInput({ personId: PERSON_ID, fields: FIELDS_PROPERTY }, ["personId", "fields"]),
|
|
1219
|
+
outputSchema: PERSON_SCHEMA,
|
|
1220
|
+
handler: async (args, ctx) => {
|
|
1221
|
+
const fields = fieldsJson(args["fields"]);
|
|
1222
|
+
const payload = await get("/people/update", { person_id: args["personId"], fields_json: fields }, ctx);
|
|
1223
|
+
requireTrue(payload, "the person update");
|
|
1224
|
+
const person = Array.isArray(payload) ? payload[0] : payload;
|
|
1225
|
+
return asRecord(person)["id"] ? projectPerson(person) : { id: String(args["personId"]) };
|
|
1226
|
+
},
|
|
1227
|
+
},
|
|
1228
|
+
{
|
|
1229
|
+
name: "assign_tag",
|
|
1230
|
+
description: "Add one tag to one person. Tag ids come from list_tags; adding a tag the person already has changes nothing.",
|
|
1231
|
+
annotations: additive,
|
|
1232
|
+
inputSchema: namedInput({ personId: PERSON_ID, tagId: idProperty("Tag id from list_tags.") }, ["personId", "tagId"]),
|
|
1233
|
+
outputSchema: WROTE_SCHEMA("assigned"),
|
|
1234
|
+
handler: async (args, ctx) => {
|
|
1235
|
+
requireTrue(await get("/tags/assign", { person_id: args["personId"], tag_id: args["tagId"] }, ctx), "the tag assignment");
|
|
1236
|
+
return { assigned: true };
|
|
1237
|
+
},
|
|
1238
|
+
},
|
|
1239
|
+
{
|
|
1240
|
+
name: "unassign_tag",
|
|
1241
|
+
description: "Remove one tag from one person. The tag itself and its other people are untouched.",
|
|
1242
|
+
annotations: destructive,
|
|
1243
|
+
inputSchema: namedInput({ personId: PERSON_ID, tagId: idProperty("Tag id from list_tags.") }, ["personId", "tagId"]),
|
|
1244
|
+
outputSchema: WROTE_SCHEMA("unassigned"),
|
|
1245
|
+
handler: async (args, ctx) => {
|
|
1246
|
+
requireTrue(await get("/tags/unassign", { person_id: args["personId"], tag_id: args["tagId"] }, ctx), "the tag removal");
|
|
1247
|
+
return { unassigned: true };
|
|
1248
|
+
},
|
|
1249
|
+
},
|
|
1250
|
+
{
|
|
1251
|
+
name: "record_check_in",
|
|
1252
|
+
description: "Record one person's attendance at one event instance. Check-out and removing a record go through breeze_api_mutate.",
|
|
1253
|
+
annotations: additive,
|
|
1254
|
+
inputSchema: namedInput({ personId: PERSON_ID, instanceId: INSTANCE_ID }, ["personId", "instanceId"]),
|
|
1255
|
+
outputSchema: WROTE_SCHEMA("checkedIn"),
|
|
1256
|
+
handler: async (args, ctx) => {
|
|
1257
|
+
requireTrue(await get("/events/attendance/add", {
|
|
1258
|
+
person_id: args["personId"], instance_id: args["instanceId"], direction: "in",
|
|
1259
|
+
}, ctx), "the check-in");
|
|
1260
|
+
return { checkedIn: true };
|
|
1261
|
+
},
|
|
1262
|
+
},
|
|
1263
|
+
];
|
|
1264
|
+
}
|
|
1265
|
+
// --- Guide -------------------------------------------------------------------
|
|
1266
|
+
function usageGuide(subdomain, purpose, instructions) {
|
|
1267
|
+
const churchInstructions = instructions?.trim();
|
|
1268
|
+
return `# Breeze ChMS usage
|
|
1269
|
+
|
|
1270
|
+
Church database for ${subdomain}.breezechms.com (Breeze ChMS, now Tithely Church Management): people, tags, events, attendance, forms, volunteers, and giving.
|
|
1271
|
+
|
|
1272
|
+
Church purpose: ${purpose}
|
|
1273
|
+
|
|
1274
|
+
## Sensitive data: reduce in code
|
|
1275
|
+
|
|
1276
|
+
People records carry home addresses, phones, birthdates, and family ties, and
|
|
1277
|
+
giving records reveal who gives and how much — religious affiliation by proxy.
|
|
1278
|
+
Read them inside \`execute_code\` and return only the counts, totals, or the
|
|
1279
|
+
few fields the task needs. Never echo whole profiles or donor lists.
|
|
1280
|
+
|
|
1281
|
+
## Ids and profile fields
|
|
1282
|
+
|
|
1283
|
+
- Ids are numeric strings. Read them from a list tool; never guess one.
|
|
1284
|
+
- Events have two ids: an instance id (\`list_events\` \`id\`) for one
|
|
1285
|
+
occurrence, and a series \`eventId\`. Attendance, volunteers, and
|
|
1286
|
+
check-in take the instance id.
|
|
1287
|
+
- Person \`details\` are keyed by profile \`fieldId\`, not by name. Call
|
|
1288
|
+
\`list_profile_fields\` once and map ids to names in code. Multiple-choice
|
|
1289
|
+
values and \`list_people\` filters use option ids from the same call;
|
|
1290
|
+
join several with \`-\`.
|
|
1291
|
+
- \`list_people\` has no name search. List without details and match names
|
|
1292
|
+
in code, then \`get_person\` the hits.
|
|
1293
|
+
- Projections rename Breeze's snake_case fields to camelCase and drop photo
|
|
1294
|
+
paths and internal columns; \`raw: true\` returns Breeze's rows.
|
|
1295
|
+
|
|
1296
|
+
## Pagination
|
|
1297
|
+
|
|
1298
|
+
\`list_people\` pages by \`cursor\`: continue while \`page.hasMore\`. Breeze
|
|
1299
|
+
reports no total, so a full page sets \`hasMore\` and the next may be empty.
|
|
1300
|
+
\`list_events\` and \`list_account_log\` do not page: \`truncated: true\`
|
|
1301
|
+
means narrow the date range. Other lists return everything.
|
|
1302
|
+
|
|
1303
|
+
## Giving
|
|
1304
|
+
|
|
1305
|
+
\`list_contributions\` and \`list_funds\` call endpoints Breeze removed from
|
|
1306
|
+
its public reference in 2023 but still serves. They may change or vanish
|
|
1307
|
+
without notice; a failure there is not your argument's fault. Amounts are
|
|
1308
|
+
decimal strings; \`totalAmount\` is an exact sum. Online gifts processed by
|
|
1309
|
+
Tithe.ly arrive through its sync, batched per deposit, so the latest days
|
|
1310
|
+
may be missing. Read the currency from \`get_account_summary\`.
|
|
1311
|
+
|
|
1312
|
+
## Writes and the hatches
|
|
1313
|
+
|
|
1314
|
+
- Every write, \`add_person\`, \`assign_tag\`, and \`record_check_in\`
|
|
1315
|
+
included, is a \`call_destructive_tool\` call unless the deployment exempts
|
|
1316
|
+
it in \`execute.approval\`; a program inside \`execute_code\` is refused
|
|
1317
|
+
the rest. Those three only add, and \`add_person\` never deduplicates.
|
|
1318
|
+
- \`update_person\` field values: text, date (M/D/YYYY), or option id in
|
|
1319
|
+
\`response\`; email, phone, address, and family_role values in \`details\`.
|
|
1320
|
+
- Every Breeze call is a GET, writes included, so the hatches split by
|
|
1321
|
+
endpoint. \`breeze_api_get\` admits only reviewed read endpoints and their
|
|
1322
|
+
documented parameters, such as \`/tags/list_folders\`,
|
|
1323
|
+
\`/events/list_event\`, \`/events/attendance/eligible\`, \`/giving/view\`,
|
|
1324
|
+
and \`/pledges/list_campaigns\`; it refuses anything else. Everything else
|
|
1325
|
+
— event, family, volunteer, and form writes, giving writes, check-out,
|
|
1326
|
+
undocumented parameters — is \`breeze_api_mutate\`. Paths are below
|
|
1327
|
+
\`/api\`; \`*_json\` values may be passed as objects.
|
|
1328
|
+
- \`list_account_log\` actions include person_created, person_updated,
|
|
1329
|
+
person_deleted, tag_assign, tag_unassign, event_created,
|
|
1330
|
+
attendance_deleted, form_entry_updated, contribution_added,
|
|
1331
|
+
contribution_updated, contribution_deleted, and batch_updated.
|
|
1332
|
+
|
|
1333
|
+
## Rate limits
|
|
1334
|
+
|
|
1335
|
+
Breeze publishes no limit and sends no rate headers; a third party reports
|
|
1336
|
+
about 20 requests per minute. Keep loops sequential and prefer one list call
|
|
1337
|
+
over many single reads.
|
|
1338
|
+
${churchInstructions ? `\n## Church instructions\n\n${churchInstructions}\n` : ""}`;
|
|
1339
|
+
}
|
|
1340
|
+
// --- Construction --------------------------------------------------------------
|
|
1341
|
+
function normalizeSubdomain(raw) {
|
|
1342
|
+
const value = typeof raw === "string" ? raw.trim().toLowerCase() : "";
|
|
1343
|
+
if (!SUBDOMAIN_PATTERN.test(value)) {
|
|
1344
|
+
throw new Error('breeze() subdomain must be the one hostname label before .breezechms.com — "gracechurch" for gracechurch.breezechms.com — not a URL or host.');
|
|
1345
|
+
}
|
|
1346
|
+
return value;
|
|
1347
|
+
}
|
|
1348
|
+
/** A maintained Breeze ChMS connection for one church's subdomain. */
|
|
1349
|
+
export function breeze(id, options) {
|
|
1350
|
+
const purpose = typeof options.purpose === "string" ? options.purpose.trim() : "";
|
|
1351
|
+
if (!purpose) {
|
|
1352
|
+
throw new Error("breeze() requires a non-empty church purpose.");
|
|
1353
|
+
}
|
|
1354
|
+
const subdomain = normalizeSubdomain(options.subdomain);
|
|
1355
|
+
const defaultPageSize = options.defaultPageSize ?? DEFAULT_PAGE_SIZE;
|
|
1356
|
+
if (!Number.isInteger(defaultPageSize) ||
|
|
1357
|
+
defaultPageSize < 1 ||
|
|
1358
|
+
defaultPageSize > MAX_PAGE_SIZE) {
|
|
1359
|
+
throw new Error(`breeze() defaultPageSize must be a whole number between 1 and ${MAX_PAGE_SIZE}.`);
|
|
1360
|
+
}
|
|
1361
|
+
const send = breezeTransport(subdomain);
|
|
1362
|
+
return api(id, {
|
|
1363
|
+
...defined({
|
|
1364
|
+
authScope: options.authScope,
|
|
1365
|
+
callAdmission: options.callAdmission,
|
|
1366
|
+
maxResultBytes: options.maxResultBytes,
|
|
1367
|
+
}),
|
|
1368
|
+
title: options.title ?? `Breeze ChMS (${subdomain})`,
|
|
1369
|
+
description: `Breeze ChMS church database at ${subdomain}${BREEZE_HOST_SUFFIX}: ${purpose}`,
|
|
1370
|
+
credential: {
|
|
1371
|
+
label: "Breeze API key",
|
|
1372
|
+
description: `The church's API key from Breeze → Extensions → API (https://${subdomain}${BREEZE_HOST_SUFFIX}/extensions/api). It has no scopes: it reaches the whole account, giving included. The connector sends it only to ${subdomain}${BREEZE_HOST_SUFFIX}.`,
|
|
1373
|
+
placeholder: "Paste Breeze API key",
|
|
1374
|
+
},
|
|
1375
|
+
testCredential: async (value, ctx) => {
|
|
1376
|
+
try {
|
|
1377
|
+
const account = asRecord(await callBreeze(send, { method: "GET", path: "/account/summary" }, { ...ctx, credential: { get: async () => value, getAll: async () => ({ value }) } }));
|
|
1378
|
+
const reported = typeof account["subdomain"] === "string" ? account["subdomain"].toLowerCase() : undefined;
|
|
1379
|
+
if (reported && reported !== subdomain) {
|
|
1380
|
+
return { ok: false, message: `The key belongs to ${reported}${BREEZE_HOST_SUFFIX}, not ${subdomain}${BREEZE_HOST_SUFFIX}.` };
|
|
1381
|
+
}
|
|
1382
|
+
return { ok: true, message: `Authenticated to ${String(account["name"] ?? "the church account")} (${subdomain}${BREEZE_HOST_SUFFIX}).` };
|
|
1383
|
+
}
|
|
1384
|
+
catch (error) {
|
|
1385
|
+
return {
|
|
1386
|
+
ok: false,
|
|
1387
|
+
message: error instanceof ConnectorCallError ? error.message : "Breeze rejected the key.",
|
|
1388
|
+
};
|
|
1389
|
+
}
|
|
1390
|
+
},
|
|
1391
|
+
usageGuide: {
|
|
1392
|
+
content: usageGuide(subdomain, purpose, options.instructions),
|
|
1393
|
+
summary: "Sensitive church data, fieldId decoding, two id kinds, undocumented giving reads, and endpoint-split hatches.",
|
|
1394
|
+
// Required because correct use rests on conventions no schema carries:
|
|
1395
|
+
// person details are keyed by profile fieldId, filters and multiple-
|
|
1396
|
+
// choice values are option ids joined by '-', and the giving reads ride
|
|
1397
|
+
// undocumented endpoints whose failures mean something different.
|
|
1398
|
+
required: true,
|
|
1399
|
+
},
|
|
1400
|
+
tools: tools(send, defaultPageSize),
|
|
1401
|
+
});
|
|
1402
|
+
}
|