@planescrm/sdk 0.4.0 → 0.55.0
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/README.md +423 -12
- package/dist/index.d.mts +3669 -183
- package/dist/index.d.ts +3669 -183
- package/dist/index.js +1737 -44
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1685 -44
- package/dist/index.mjs.map +1 -1
- package/package.json +17 -13
package/README.md
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
|
-
# @
|
|
1
|
+
# @planescrm/sdk
|
|
2
2
|
|
|
3
3
|
TypeScript SDK for the [Planes CRM](https://planescrm.com) platform — a typed API
|
|
4
4
|
client, an ergonomic webhook router, OAuth helpers, and an action-handler server
|
|
5
5
|
for building agents.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
```bash
|
|
8
|
+
npm install @planescrm/sdk
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
**Self-contained types (0.52.1).** The built package imports only Node builtins
|
|
12
|
+
and its own dependencies — never the private `@blueprint/shared`. Types the API
|
|
13
|
+
shares are mirrored under `src/types/*-types.ts` and checked against shared at
|
|
14
|
+
compile time by `*.parity.ts` files, which are not reachable from `index.ts`.
|
|
15
|
+
`build` runs `scripts/check-shipped-imports.mjs` after `tsup` and fails if
|
|
16
|
+
`dist/` or a shipped source file imports anything else.
|
|
12
17
|
|
|
13
18
|
## Client
|
|
14
19
|
|
|
@@ -16,7 +21,7 @@ for building agents.
|
|
|
16
21
|
exactly **one** authentication mode.
|
|
17
22
|
|
|
18
23
|
```ts
|
|
19
|
-
import { PlanesClient } from '@
|
|
24
|
+
import { PlanesClient } from '@planescrm/sdk';
|
|
20
25
|
|
|
21
26
|
// 1. A pre-obtained OAuth access token (not auto-refreshed).
|
|
22
27
|
const client = new PlanesClient({ accessToken: 'tok_...' });
|
|
@@ -36,7 +41,413 @@ const opp = await client.opportunities.get('opp_123');
|
|
|
36
41
|
```
|
|
37
42
|
|
|
38
43
|
Resources: `opportunities`, `customers`, `properties`, `calendarEvents`,
|
|
39
|
-
`contracts`, `jobs`, `estimates`, `invoices`, `inventory
|
|
44
|
+
`contracts`, `jobs`, `estimates`, `invoices`, `inventory`, `contacts`,
|
|
45
|
+
`activities`, `communications`, `gmail`, `documents`, `tags`, `tasks`,
|
|
46
|
+
`customFields`, `recordViews`, `modules`, `plan`, `me`, `roles`, `contactSequences`,
|
|
47
|
+
`enrollments`, `workflows`, `billingSchedules`.
|
|
48
|
+
|
|
49
|
+
### Breaking changes (0.43.0)
|
|
50
|
+
|
|
51
|
+
Twenty methods called routes the API doesn't serve. Every old call failed with
|
|
52
|
+
a 404 or 405, so no working integration depended on them. Each one now follows
|
|
53
|
+
the real route, and a method the API doesn't have is gone, so calling it is a
|
|
54
|
+
compile error instead of a runtime 404.
|
|
55
|
+
|
|
56
|
+
| Resource | Before (never worked) | Now |
|
|
57
|
+
|---|---|---|
|
|
58
|
+
| `properties` | `list(params)`, `create(body)`, `update(id, body)` (PATCH), `delete(id)` on `/properties` | `list(customerId, params)`, `create(customerId, body)`, `update(customerId, id, body)` (PUT), `delete(customerId, id)` on `/customers/{customerId}/properties`. `get(id)` is unchanged (`GET /properties/{id}`). |
|
|
59
|
+
| `jobs` | `list`, `get`, `create`, `update` (PATCH), `delete` on `/jobs` | `list(opportunityId, params)`, `get(opportunityId, id)`, `create(opportunityId, body)`, `update(opportunityId, id, body)` (PUT), `delete(opportunityId, id)` on `/opportunities/{opportunityId}/jobs`. `listForOpportunity()` and `getTradeCoverage()` are unchanged. |
|
|
60
|
+
| `estimates` | `list`, `get`, `create`, `update` (PATCH), `delete` on `/estimates` | `list(jobId, params)`, `get(jobId, id)`, `create(jobId, body)`, `update(jobId, id, body)` (PUT), `delete(jobId, id)` on `/jobs/{jobId}/estimates`. `listForOpportunity()` is unchanged. |
|
|
61
|
+
| `inventory` | CRUD on `/inventory/items`, `update` sent PATCH | Same methods on `/admin/inventory/items`; `update` sends PUT. |
|
|
62
|
+
| `opportunities` | `update(id, body)` sent PATCH | `update(id, body)` sends PUT; the body is typed `UpdateOpportunityDto`. |
|
|
63
|
+
| `tasks` | `delete(id)` | Removed (there is no delete route). Use `cancel(id)`; `reopen(id)` brings a task back. |
|
|
64
|
+
| `tags` | `get(id)` | Removed (there is no single-tag read). Use `list()` or `getEntities(id)`. |
|
|
65
|
+
|
|
66
|
+
New request types, mirroring the API's validation schemas: `CreatePropertyDto`,
|
|
67
|
+
`UpdatePropertyDto`, `PropertyListParams`, `CreateJobDto`, `UpdateJobDto`,
|
|
68
|
+
`CreateEstimateDto`, `UpdateEstimateDto`, `CreateInventoryItemDto`,
|
|
69
|
+
`UpdateInventoryItemDto`, `InventoryItemListParams`, `UpdateOpportunityDto`.
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
const { data: properties } = await client.properties.list('cust_123');
|
|
73
|
+
const job = await client.jobs.create('opp_123', { tradeId: 'trade_123' });
|
|
74
|
+
await client.estimates.update('job_123', 'est_123', { taxRate: 0.0825 });
|
|
75
|
+
await client.tasks.cancel('task_123');
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Invoices
|
|
79
|
+
|
|
80
|
+
`client.invoices.list({ status, customerId, page, limit })` returns typed
|
|
81
|
+
`InvoiceListItemDto` rows. Each row carries `customerName` — the company name
|
|
82
|
+
for a company customer, "First Last" for a person — so a list view needs no
|
|
83
|
+
follow-up `customers.get()` per row:
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
const { data } = await client.invoices.list({ status: 'sent' });
|
|
87
|
+
data.forEach((inv) => console.log(inv.invoiceNumber, inv.customerName, inv.balance));
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Consent state (0.33.0)
|
|
91
|
+
|
|
92
|
+
Every contact channel the API returns (`contacts.get`, `contacts.list({ include: ['channels'] })`,
|
|
93
|
+
channel create/update) and `GET /customers/:id` / `PUT /customers/:id` carry a `consent`
|
|
94
|
+
object (`ConsentStateDto`): the record's own `marketing_opt_out` / `dnc` flags,
|
|
95
|
+
`effective_opted_out` (`marketing_email` / `sms` / `call`) — the by-address verdict every send
|
|
96
|
+
honours, which can be true while the record's own flag is false because another record with the
|
|
97
|
+
same address holds the opt-out — and `last_change` (`{ outcome, source, at, scope }` or `null`).
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
const { data } = await client.contacts.list({ customer_id: customerId, include: ['channels'] });
|
|
101
|
+
for (const ch of data.flatMap((c) => c.channels ?? [])) {
|
|
102
|
+
if (ch.consent.effective_opted_out.marketing_email && !ch.consent.marketing_opt_out) {
|
|
103
|
+
console.log(ch.value, 'is opted out via another record', ch.consent.last_change?.source);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
`client.invoices.downloadPdf(id)` (0.36.0) returns the invoice PDF as an
|
|
107
|
+
`ArrayBuffer`. A sent invoice returns the copy stored when it was sent, and a
|
|
108
|
+
draft renders on demand with a DRAFT watermark:
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
import { writeFile } from 'node:fs/promises';
|
|
112
|
+
await writeFile('INV-0001.pdf', Buffer.from(await client.invoices.downloadPdf(invoiceId)));
|
|
113
|
+
### Overdue invoices (0.35.0)
|
|
114
|
+
|
|
115
|
+
`client.invoices.listOverdue({ minDays, customerId, page, limit })` returns
|
|
116
|
+
sent / partially paid invoices past their due date, most overdue first, with
|
|
117
|
+
`daysOverdue` and `lastOffsetReached`; `client.invoices.overdueSummary()`
|
|
118
|
+
returns `{ count, total }`. A daily sweep emits the `invoice.overdue` webhook
|
|
119
|
+
event (typed `InvoiceOverdueEvent`) once per invoice at each of the company's
|
|
120
|
+
offsets — days after the due date, default `[3, 7, 14]`, read and set (admin)
|
|
121
|
+
with `client.invoiceSettings.getOverdueOffsets()` / `setOverdueOffsets([...])`:
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
const { data } = await client.invoices.listOverdue({ minDays: 7 });
|
|
125
|
+
const { count, total } = await client.invoices.overdueSummary();
|
|
126
|
+
await client.invoiceSettings.setOverdueOffsets([5, 10, 30]); // 1–90 days, up to 5
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Invoice emails attach that stored copy (as `INV-0001.pdf`) unless an admin turns
|
|
130
|
+
it off: `client.invoiceSettings.setAttachPdf(false)` / `getAttachPdf()` (0.39.0;
|
|
131
|
+
the write needs Update Tenant).
|
|
132
|
+
|
|
133
|
+
Payment reminders (0.41.0, admin only): `client.invoiceReminders.list()` returns
|
|
134
|
+
the rules and the overdue offsets (a rule at an offset the sweep never emits is
|
|
135
|
+
`willNeverFire`); `update(rules)` replaces the whole set (unknown placeholders are
|
|
136
|
+
a 422 `REMINDER_TEMPLATE_INVALID`); `preview({ subject, body, invoiceId? })`
|
|
137
|
+
renders exactly what would send, without sending:
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
await client.invoiceReminders.update([
|
|
141
|
+
{ offsetDays: 7, enabled: true, subject: 'Invoice {{invoiceNumber}} is past due', body: 'Hi {{customerName}}, {{amountDue}} was due {{dueDate}}.' },
|
|
142
|
+
]);
|
|
143
|
+
const { subject, body } = await client.invoiceReminders.preview({ subject: '…', body: '…', invoiceId });
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### Billing schedules (0.32.0)
|
|
147
|
+
### Billing schedules (0.32.0; line versions 0.34.0; adjustments 0.38.0; autopay summary 0.46.0)
|
|
148
|
+
|
|
149
|
+
`client.billingSchedules` manages recurring invoices (`/admin/billing-schedules`,
|
|
150
|
+
invoicing module). Fields are snake_case, money is a 2-decimal string, and
|
|
151
|
+
dates are `YYYY-MM-DD`:
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
const schedule = await client.billingSchedules.create({
|
|
155
|
+
customer_id: 'cus_123',
|
|
156
|
+
name: 'Monthly maintenance',
|
|
157
|
+
cadence: 'monthly',
|
|
158
|
+
start_date: '2026-11-01', // a past date also needs backfill: 'skip' | 'draft'
|
|
159
|
+
lines: [{ description: 'Lawn care', quantity: '1', unit_price: '120.00' }],
|
|
160
|
+
}); // timezone defaults to the tenant's
|
|
161
|
+
|
|
162
|
+
await client.billingSchedules.preview(schedule.id, { count: 12 }); // next 12 periods
|
|
163
|
+
// A price change from a date: periods starting on or after it bill the new lines.
|
|
164
|
+
await client.billingSchedules.update(schedule.id, {
|
|
165
|
+
lines: [{ description: 'Lawn care', quantity: '1', unit_price: '135.00' }],
|
|
166
|
+
effective_from: '2027-01-01',
|
|
167
|
+
});
|
|
168
|
+
await client.billingSchedules.listVersions(schedule.id); // line version history
|
|
169
|
+
// A one-off credit (or charge) on the next invoice only — no automatic proration.
|
|
170
|
+
const credit = await client.billingSchedules.addAdjustment(schedule.id, {
|
|
171
|
+
description: 'Credit: 10 days not serviced',
|
|
172
|
+
amount: '-40.00',
|
|
173
|
+
});
|
|
174
|
+
await client.billingSchedules.listAdjustments(schedule.id); // pending + consumed
|
|
175
|
+
await client.billingSchedules.cancelAdjustment(schedule.id, credit.id); // only while pending
|
|
176
|
+
await client.billingSchedules.periods(schedule.id, { status: 'failed' }); // the period ledger
|
|
177
|
+
await client.billingSchedules.needsReview(); // tenant-wide queue
|
|
178
|
+
await client.billingSchedules.autopaySummary(); // { data: { autopaySchedules, customers, savedCards } } before a Stripe disconnect
|
|
179
|
+
await client.billingSchedules.generateNow(schedule.id); // bill the current due period now
|
|
180
|
+
await client.billingSchedules.skipPeriod(schedule.id, '2026-12-01', { reason: 'Away' });
|
|
181
|
+
await client.billingSchedules.regeneratePeriod(schedule.id, '2026-11-01'); // after a void
|
|
182
|
+
await client.billingSchedules.pause(schedule.id); // also resume(id), end(id)
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Also `list({ customer_id, status, page, limit })`, `get(id)` (with
|
|
186
|
+
`upcoming_periods`), `update(id, patch)` (`lines` replaces every line) and
|
|
187
|
+
`delete(id)` (only a schedule that never invoiced).
|
|
188
|
+
|
|
189
|
+
### Completing calendar events (0.44.0)
|
|
190
|
+
|
|
191
|
+
`client.calendarEvents.complete(id, { completion_notes? })` marks an event
|
|
192
|
+
completed and runs its "on complete" transition, best-effort. The event is
|
|
193
|
+
always completed; `workflow_trigger` says whether the transition ran:
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
const res = await client.calendarEvents.complete('evt_123');
|
|
197
|
+
if (res.workflow_trigger.status === 'failed') {
|
|
198
|
+
// e.g. reason 'not_valid_from_current_stage' or 'wrong_pipeline'
|
|
199
|
+
console.warn(res.workflow_trigger.message);
|
|
200
|
+
}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
`status` is `fired`, `skipped` (no transition set) or `failed` (with a
|
|
204
|
+
`reason` and `message`; the tenant's admins also get a notification).
|
|
205
|
+
Since 0.55.0 a `fired` trigger carries `to_stage: { id, name }`, the stage the
|
|
206
|
+
transition moved the record to; `tasks.complete(id)` returns the same
|
|
207
|
+
`workflow_trigger`:
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
const { workflow_trigger } = await client.tasks.complete('task_123');
|
|
211
|
+
if (workflow_trigger.status === 'fired') console.log(`Moved to ${workflow_trigger.to_stage?.name}`);
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
Actions on that transition run afterwards, so a chained transition may move the
|
|
215
|
+
record again later; `to_stage` doesn't report that later move. Creating
|
|
216
|
+
or updating an event with a `completion_workflow_transition_id` from a pipeline
|
|
217
|
+
other than its linked record's is rejected with a 422
|
|
218
|
+
`COMPLETION_TRANSITION_WRONG_PIPELINE`.
|
|
219
|
+
|
|
220
|
+
### Updating recurring calendar events (0.54.0)
|
|
221
|
+
|
|
222
|
+
`client.calendarEvents.update(id, patch, { editMode })` chooses which
|
|
223
|
+
occurrences of a recurring event change: `this` (only this occurrence, the
|
|
224
|
+
default), `this_and_future` (splits the series here) or `all` (the whole
|
|
225
|
+
series). It is sent as the `recurrence_edit_mode` query parameter and ignored
|
|
226
|
+
for one-time events. `this` on a series master is a 400 `INVALID_EDIT_MODE`.
|
|
227
|
+
|
|
228
|
+
```ts
|
|
229
|
+
await client.calendarEvents.update('evt_123', { starts_at: '2026-11-02T15:00:00' }, { editMode: 'this_and_future' });
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
A blank `recurrence_rule` in the patch means "doesn't repeat": it is ignored
|
|
233
|
+
rather than stored, so it never changes an existing series.
|
|
234
|
+
|
|
235
|
+
### Recently viewed
|
|
236
|
+
|
|
237
|
+
`client.recordViews.touch({ entity_type, entity_id })` records that the calling
|
|
238
|
+
user opened a customer / contact / opportunity / job (idempotent; 204 even for
|
|
239
|
+
an id that no longer exists). `client.recordViews.recent({ limit })` returns
|
|
240
|
+
that user's newest views. The four list calls accept `sort_by: 'recent'` —
|
|
241
|
+
the caller's most recently opened records first, never-viewed last
|
|
242
|
+
(`sort_order` is ignored for it):
|
|
243
|
+
|
|
244
|
+
```ts
|
|
245
|
+
await client.recordViews.touch({ entity_type: 'customer', entity_id: 'cus_123' });
|
|
246
|
+
const { data } = await client.customers.list({ sort_by: 'recent' });
|
|
247
|
+
const jobs = await client.jobs.listForOpportunity('opp_123', { sort_by: 'recent' });
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
### Module status
|
|
251
|
+
|
|
252
|
+
`client.modules.list()` returns `{ moduleKey, isActive }` for every registered
|
|
253
|
+
module in the caller's tenant (any role; read-only). A module that was never
|
|
254
|
+
activated reads `isActive: false`:
|
|
255
|
+
|
|
256
|
+
```ts
|
|
257
|
+
const modules = await client.modules.list();
|
|
258
|
+
const contactsOn = modules.some((m) => m.moduleKey === 'contacts' && m.isActive);
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
### Plan
|
|
262
|
+
|
|
263
|
+
`client.plan.get()` returns the caller tenant's plan (any role; read-only):
|
|
264
|
+
`planKey`, `label`, `billingStatus`, `planSource`, `trialEndsAt`,
|
|
265
|
+
`billingInterval`, `priceLockedUntil`, and the plan's ceiling — the `modules`
|
|
266
|
+
and `features` it may enable, `includedFullSeats`, `crewSeatsAllowed`,
|
|
267
|
+
`monthlyAiCredits` and `storageGb`. A ceiling is not activation: check
|
|
268
|
+
`client.modules.list()` for what is switched on. Plans change in Settings →
|
|
269
|
+
Billing, not through the API.
|
|
270
|
+
|
|
271
|
+
```ts
|
|
272
|
+
const plan = await client.plan.get();
|
|
273
|
+
const canInvoice = plan.modules.includes('invoicing');
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
### Caller abilities
|
|
277
|
+
|
|
278
|
+
`client.me.abilities()` returns the caller's effective ability — the exact
|
|
279
|
+
rules the server enforces — as `{ role, actingRole?, version, rules }`.
|
|
280
|
+
`rules` are CASL packed rules; for an API key, `actingRole` is the acting
|
|
281
|
+
agent's live role and the rules are the key role ∩ that role. `version`
|
|
282
|
+
matches the `X-Planes-Abilities-Version` header on every authenticated
|
|
283
|
+
response, so a changed header means refetch:
|
|
284
|
+
|
|
285
|
+
```ts
|
|
286
|
+
import { createMongoAbility } from '@casl/ability';
|
|
287
|
+
import { unpackRules } from '@casl/ability/extra';
|
|
288
|
+
|
|
289
|
+
const { role, rules } = await client.me.abilities();
|
|
290
|
+
const ability = createMongoAbility(unpackRules(rules));
|
|
291
|
+
ability.can('delete', 'Customer');
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
### Roles (admin only)
|
|
295
|
+
|
|
296
|
+
`client.roles` manages Settings → Roles (0.19.0). Every call needs an admin
|
|
297
|
+
caller — any other role gets a 403 `FORBIDDEN_BY_ROLE`. `list()` returns
|
|
298
|
+
`{ data: [{ key, label, tunable, userCount, changeCount }] }`; `get(key)` one
|
|
299
|
+
role's matrix (`{ key, label, tunable, summary, version, rows: [{ rowId,
|
|
300
|
+
cells: [{ action, subject, value, default, tier, locked }] }] }`).
|
|
301
|
+
`updateGrants(key, body)` sends sparse cell changes (`effect: 'allow' | 'deny' |
|
|
302
|
+
'default'`); granting a money action (approve, mark paid, correct, payments)
|
|
303
|
+
needs `confirm: true` and a `reason`, and passing the `version` you read as
|
|
304
|
+
`baseVersion` turns a concurrent edit into a 409. `reset(key)` returns a role
|
|
305
|
+
to the Planes defaults; `history({ role, cursor, limit })` pages the change log:
|
|
306
|
+
|
|
307
|
+
```ts
|
|
308
|
+
const sales = await client.roles.get('sales');
|
|
309
|
+
await client.roles.updateGrants('sales', {
|
|
310
|
+
changes: [{ action: 'delete', subject: 'Customer', effect: 'allow' }],
|
|
311
|
+
baseVersion: sales.version,
|
|
312
|
+
});
|
|
313
|
+
const { data, cursor } = await client.roles.history({ role: 'sales', limit: 20 });
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
### Contact sequences
|
|
317
|
+
|
|
318
|
+
`client.contactSequences.list()` returns the tenant's published contact
|
|
319
|
+
sequences as `{ data: [{ id, name, description }], meta: { total } }`
|
|
320
|
+
(name-sorted, max 100), readable by any role that can enroll a contact. Each
|
|
321
|
+
`id` is the sequence's lineage root — the `workflow_id` that
|
|
322
|
+
`POST /contacts/:id/enrollments` accepts:
|
|
323
|
+
|
|
324
|
+
```ts
|
|
325
|
+
const { data: sequences } = await client.contactSequences.list();
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
### Sequence enrollments
|
|
329
|
+
|
|
330
|
+
`client.enrollments` enrolls a contact in a sequence (the `id` from
|
|
331
|
+
`contactSequences.list()`), lists a contact's enrollments, and exits one:
|
|
332
|
+
|
|
333
|
+
```ts
|
|
334
|
+
const enrollment = await client.enrollments.enroll(contactId, sequences[0].id);
|
|
335
|
+
// 409 if already active in that sequence; 422 if it has no published version
|
|
336
|
+
|
|
337
|
+
const active = await client.enrollments.list(contactId, { status: 'active' });
|
|
338
|
+
// bare array, newest first — each row carries sequenceName + currentStageName
|
|
339
|
+
|
|
340
|
+
await client.enrollments.exit(enrollment.id); // reason 'manual'; idempotent
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
### Workflow version stages
|
|
344
|
+
|
|
345
|
+
`client.workflows.getStages(workflowId)` returns the ordered stages of one
|
|
346
|
+
workflow version — `{ id, name, entityType, version, status, stages: [{ id,
|
|
347
|
+
name, color, icon, sortOrder, isInitial, isTerminal }] }`. Pass a record's
|
|
348
|
+
instance `workflowId` to draw its progress against the version it is pinned
|
|
349
|
+
to, even an archived one after a newer version was published. Readable by any
|
|
350
|
+
role that can read the workflow's record type (403 otherwise; 404 for an
|
|
351
|
+
unknown or draft id):
|
|
352
|
+
|
|
353
|
+
```ts
|
|
354
|
+
const { stages } = await client.workflows.getStages(instance.workflowId);
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
A stage's `color` is a hex code or `null`. The API only accepts `#RGB` or
|
|
358
|
+
`#RRGGBB` when a stage is written, and stores it as uppercase `#RRGGBB`.
|
|
359
|
+
A stage saved before that rule may still hold some other string, so check
|
|
360
|
+
the value before using it as a CSS colour.
|
|
361
|
+
|
|
362
|
+
### Workflow transition summaries
|
|
363
|
+
|
|
364
|
+
`client.workflows.listTransitions({ entityTypes, workflowId })` returns
|
|
365
|
+
`{ transitions: [{ id, name, from_stage_name, to_stage_name, workflow_name,
|
|
366
|
+
entity_type, workflow_id, workflow_version, lineage_workflow_id,
|
|
367
|
+
automatic_only }] }`. With no `workflowId` it lists every published
|
|
368
|
+
workflow's transitions; with one it lists that version's, published or
|
|
369
|
+
archived, so a record pinned to an older version gets its own steps (an
|
|
370
|
+
unknown, draft or other-tenant id returns an empty list). `lineage_workflow_id`
|
|
371
|
+
is the same for every version of one pipeline. Both filters together are an
|
|
372
|
+
AND. Only pipelines whose record type the caller can read are listed (the
|
|
373
|
+
same rule as `getStages`): a `workflowId` of a type the key's role can't read
|
|
374
|
+
returns an empty list, not 403, and an unreadable type in `entityTypes` is
|
|
375
|
+
dropped:
|
|
376
|
+
|
|
377
|
+
```ts
|
|
378
|
+
const { transitions } = await client.workflows.listTransitions({ workflowId: instance.workflowId });
|
|
379
|
+
const manual = transitions.filter((t) => !t.automatic_only);
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
### Workflow event rules (0.51.0)
|
|
383
|
+
|
|
384
|
+
`client.workflows.eventRules(workflowId)` manages a workflow's "advance
|
|
385
|
+
automatically when…" rules on the admin workflow API (manage Workflow + the
|
|
386
|
+
`workflow_builder` feature): `list()`, `create(input)`, `update(ruleId, input)`
|
|
387
|
+
and `delete(ruleId)`. A rule fires `transitionRef` (stage NAMES, resolved
|
|
388
|
+
against the version each record is pinned to) when a related contract, invoice
|
|
389
|
+
or calendar event emits `eventType` and its `changes` match `when`. Any
|
|
390
|
+
version's id works; rules are keyed by the workflow's lineage root, so they
|
|
391
|
+
survive a publish. `list()` is a bare array, newest first; `delete()` is a soft
|
|
392
|
+
delete returning `{ id }`.
|
|
393
|
+
|
|
394
|
+
Since 0.53.0 a rule carries exactly `id`, `workflowId`, `eventType`, `when`,
|
|
395
|
+
`transitionRef`, `enabled`, `createdAt` and `updatedAt` (no `tenantId`,
|
|
396
|
+
`createdBy` or `deletedAt`), and `update`/`delete` only reach a rule through an
|
|
397
|
+
id of its own pipeline (any version); another pipeline's id is a 404
|
|
398
|
+
`EVENT_RULE_NOT_FOUND`. A path id that isn't a uuid is a 400.
|
|
399
|
+
|
|
400
|
+
```ts
|
|
401
|
+
const rules = client.workflows.eventRules(workflowId);
|
|
402
|
+
|
|
403
|
+
// Advance when the contract is sent.
|
|
404
|
+
await rules.create({
|
|
405
|
+
eventType: 'contract.status_changed',
|
|
406
|
+
when: { status: { new: 'sent' } },
|
|
407
|
+
transitionRef: { from_stage_name: 'Estimate', to_stage_name: 'Contract Sent' },
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
// Advance when an event is booked. Creation types carry no `changes`, so the
|
|
411
|
+
// only matcher is `when: {}` (see WORKFLOW_EVENT_RULE_MATCH_ALL_EVENT_TYPES).
|
|
412
|
+
const booked = await rules.create({
|
|
413
|
+
eventType: 'calendar_event.created',
|
|
414
|
+
when: {},
|
|
415
|
+
transitionRef: { from_stage_name: 'New', to_stage_name: 'Booked' },
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
await rules.update(booked.id, { enabled: false }); // only the fields sent change
|
|
419
|
+
await rules.delete(booked.id);
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
Event types: `WORKFLOW_EVENT_RULE_EVENT_TYPES` (`contract.status_changed`,
|
|
423
|
+
`invoice.status_changed`, `calendar_event.status_changed`,
|
|
424
|
+
`calendar_event.created`). A body the server rejects is a 400
|
|
425
|
+
`VALIDATION_FAILED`; an unknown workflow is 404 `WORKFLOW_NOT_FOUND`, an
|
|
426
|
+
unknown or deleted rule 404 `EVENT_RULE_NOT_FOUND`.
|
|
427
|
+
|
|
428
|
+
### Job trade coverage
|
|
429
|
+
|
|
430
|
+
`client.jobs.getTradeCoverage()` returns every active trade with whether a job
|
|
431
|
+
can be created under it — `{ trades: [{ tradeId, name, hasWorkflow }] }`,
|
|
432
|
+
ordered by name. `hasWorkflow` is true when a published job workflow resolves
|
|
433
|
+
for the trade (its own or the generic one); creating a job under a trade with
|
|
434
|
+
`hasWorkflow: false` returns 422 `WORKFLOW_NOT_CONFIGURED`. Readable by any
|
|
435
|
+
role that can create jobs (403 otherwise):
|
|
436
|
+
|
|
437
|
+
```ts
|
|
438
|
+
const { trades } = await client.jobs.getTradeCoverage();
|
|
439
|
+
const creatable = trades.filter((t) => t.hasWorkflow);
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
### Estimates across an opportunity
|
|
443
|
+
|
|
444
|
+
`client.estimates.listForOpportunity(opportunityId, { status, page, limit })`
|
|
445
|
+
returns every estimate across all of the opportunity's jobs in one request
|
|
446
|
+
(newest first, each row carrying `jobId`; `limit` max 100):
|
|
447
|
+
|
|
448
|
+
```ts
|
|
449
|
+
const { data } = await client.estimates.listForOpportunity('opp_123', { limit: 100 });
|
|
450
|
+
```
|
|
40
451
|
|
|
41
452
|
### Errors
|
|
42
453
|
|
|
@@ -44,7 +455,7 @@ Every failure throws a subclass of `PlanesError`, each carrying `status` and
|
|
|
44
455
|
`code`:
|
|
45
456
|
|
|
46
457
|
```ts
|
|
47
|
-
import { PlanesError, RateLimitError, ValidationError } from '@
|
|
458
|
+
import { PlanesError, RateLimitError, ValidationError } from '@planescrm/sdk';
|
|
48
459
|
|
|
49
460
|
try {
|
|
50
461
|
await client.opportunities.get('missing');
|
|
@@ -93,7 +504,7 @@ Use the OAuth helpers to drive the login redirect and exchange the returned code
|
|
|
93
504
|
for tokens.
|
|
94
505
|
|
|
95
506
|
```ts
|
|
96
|
-
import { buildAuthorizeUrl, exchangeCode } from '@
|
|
507
|
+
import { buildAuthorizeUrl, exchangeCode } from '@planescrm/sdk';
|
|
97
508
|
|
|
98
509
|
const baseUrl = 'https://api.planescrm.com/api/v1';
|
|
99
510
|
|
|
@@ -115,7 +526,7 @@ const tokens = await exchangeCode({
|
|
|
115
526
|
});
|
|
116
527
|
|
|
117
528
|
// 3. Seed an OAuth-managed client so refreshes happen automatically.
|
|
118
|
-
import { PlanesClient, MemoryTokenStorage } from '@
|
|
529
|
+
import { PlanesClient, MemoryTokenStorage } from '@planescrm/sdk';
|
|
119
530
|
|
|
120
531
|
const client = new PlanesClient({
|
|
121
532
|
clientId: process.env.PLANES_CLIENT_ID!,
|
|
@@ -135,7 +546,7 @@ gives a chainable builder; call `registerAction(type, handler)` per action and
|
|
|
135
546
|
`listen()` to start.
|
|
136
547
|
|
|
137
548
|
```ts
|
|
138
|
-
import { createAgentApp } from '@
|
|
549
|
+
import { createAgentApp } from '@planescrm/sdk';
|
|
139
550
|
|
|
140
551
|
createAgentApp()
|
|
141
552
|
.registerAction('enrich-opportunity', async (ctx) => {
|