@substrat-run/connector-planima 0.2.0 → 0.2.2
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/LICENSE +661 -0
- package/dist/api.d.ts +284 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +462 -0
- package/dist/api.js.map +1 -0
- package/dist/index.d.ts +429 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +646 -0
- package/dist/index.js.map +1 -0
- package/dist/mock.d.ts +122 -0
- package/dist/mock.d.ts.map +1 -0
- package/dist/mock.js +200 -0
- package/dist/mock.js.map +1 -0
- package/dist/plan.d.ts +141 -0
- package/dist/plan.d.ts.map +1 -0
- package/dist/plan.js +226 -0
- package/dist/plan.js.map +1 -0
- package/package.json +13 -13
package/dist/index.js
ADDED
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { connectionActivity, connectionId as connectionIdSchema, instant, scopeId as scopeIdSchema, tenantId as tenantIdSchema, } from '@substrat-run/contracts';
|
|
3
|
+
import { PlanimaApi, PlanimaApiError, PLANIMA_API_BASE, planimaSecret } from './api.js';
|
|
4
|
+
import { actionFactIn, buildingFact, componentFact, facilityFact, planimaActionFact, planimaBuildingFact, planimaComponentFact, planimaFacilityFact, } from './plan.js';
|
|
5
|
+
export { PlanimaApi, PlanimaApiError, PLANIMA_API_BASE, PLANIMA_ACCEPT, PLANIMA_MAX_PAGE, PLANIMA_ACTION_STATUSES, planimaSecret, } from './api.js';
|
|
6
|
+
export { decimalOf, planimaActionFact, planimaBuildingFact, planimaComponentFact, planimaDecimal, planimaFacilityFact, } from './plan.js';
|
|
7
|
+
export { PlanimaMock } from './mock.js';
|
|
8
|
+
/**
|
|
9
|
+
* The Planima connector — the INBOUND half of maintenance-plan integration.
|
|
10
|
+
*
|
|
11
|
+
* [Planima](https://planima.se/) is a Swedish web application for planned facility
|
|
12
|
+
* maintenance (sv. *underhållsplan*). A plan is a tree — organization → facility →
|
|
13
|
+
* building → component — with **actions** hanging off it: dated, priced, categorized
|
|
14
|
+
* work a property owner intends to do in a given year.
|
|
15
|
+
*
|
|
16
|
+
* ## Poll-only, and that is a design fact rather than an omission
|
|
17
|
+
*
|
|
18
|
+
* Like `connector-fortnox` and unlike `connector-scrive`, this connector registers no
|
|
19
|
+
* event handler at all, because nothing inside a scope initiates the work. A vertical
|
|
20
|
+
* does not *ask* for next year's maintenance plan the way it asks for a signature; the
|
|
21
|
+
* plan changes in Planima — a surveyor walks a roof and moves an action from 2031 to
|
|
22
|
+
* 2027 — and the platform finds out by looking.
|
|
23
|
+
*
|
|
24
|
+
* So there is no `registerPlanimaConnector`. {@link sweepPlanimaPlan} is the whole
|
|
25
|
+
* trigger surface, and a deployment binds it into the platform sweeper exactly as it
|
|
26
|
+
* binds Fortnox's.
|
|
27
|
+
*
|
|
28
|
+
* ## Read-only, and the credential is why
|
|
29
|
+
*
|
|
30
|
+
* Planima's API can create and update organizations, facilities, buildings and
|
|
31
|
+
* components. This connector uses none of it. A Planima token carries the full access
|
|
32
|
+
* of the user who minted it — there are no scopes to narrow — so the narrowing that
|
|
33
|
+
* matters is operational: hand this connector a **read-only user's token** and the
|
|
34
|
+
* blast radius of a leaked credential is a maintenance plan someone could already see.
|
|
35
|
+
* A write path would forfeit that, and no vertical has asked for one; when one does,
|
|
36
|
+
* it belongs behind an event and a dispatch, in the shape `connector-scrive` already
|
|
37
|
+
* has.
|
|
38
|
+
*
|
|
39
|
+
* ## Where the data lands, and why the connector does not decide
|
|
40
|
+
*
|
|
41
|
+
* A sweep has no delivered event, so it has neither a scope to write to nor authority
|
|
42
|
+
* to write with. That is declared **once**, explicitly, by {@link bindPlanimaScope}:
|
|
43
|
+
* which scope, which operation to land the plan through, and which permission that
|
|
44
|
+
* operation checks.
|
|
45
|
+
*
|
|
46
|
+
* The operation is the *consumer's*, and that is deliberate. What comes out of Planima
|
|
47
|
+
* is neutral fact — a component, a year, a price, a status string. What a business
|
|
48
|
+
* *means* by them (which status counts as committed spend, which category rolls into
|
|
49
|
+
* which budget line, whether a deferred action still books) is vocabulary, and
|
|
50
|
+
* vocabulary is the vertical's layer.
|
|
51
|
+
*/
|
|
52
|
+
/**
|
|
53
|
+
* The standing grants this connector requires — deliberately EMPTY, with a mechanism
|
|
54
|
+
* in place of a declaration.
|
|
55
|
+
*
|
|
56
|
+
* The reasoning is `FORTNOX_CONNECTION_GRANTS`'s, and it applies here for the same
|
|
57
|
+
* reason: the permission this connector needs is whatever the *consumer's* landing
|
|
58
|
+
* operation checks, which differs per vertical and is unknown at this package's build
|
|
59
|
+
* time. So the check moves from build time to bind time — {@link bindPlanimaScope}
|
|
60
|
+
* verifies the connection actually holds the named permission in the named scope and
|
|
61
|
+
* **refuses the binding otherwise**, naming what is missing. A sweep can therefore
|
|
62
|
+
* never be configured into a state where it fetches a whole maintenance plan and
|
|
63
|
+
* cannot write it down.
|
|
64
|
+
*/
|
|
65
|
+
export const PLANIMA_CONNECTION_GRANTS = [];
|
|
66
|
+
/** The connector-state key prefix every binding lives under — what the sweep enumerates. */
|
|
67
|
+
const BINDING_PREFIX = 'planima:binding:';
|
|
68
|
+
const bindingKey = (scope) => `${BINDING_PREFIX}${scope}`;
|
|
69
|
+
/**
|
|
70
|
+
* The currency a Planima plan's prices are in — a DECLARED fact, because the API does
|
|
71
|
+
* not carry one.
|
|
72
|
+
*
|
|
73
|
+
* Planima sends `unit_price: 1200` and nothing else: no currency field on the action,
|
|
74
|
+
* the facility, the organization or the account. The product is Swedish and its prices
|
|
75
|
+
* are kronor, so `SEK` is the right default — but it is a default this connector chose,
|
|
76
|
+
* not a value it read, and Substrat money is a `{ amount, currency }` pair that cannot
|
|
77
|
+
* be built without one. Naming it on the binding is what keeps that choice visible and
|
|
78
|
+
* overridable instead of hard-coded three files down.
|
|
79
|
+
*/
|
|
80
|
+
export const PLANIMA_DEFAULT_CURRENCY = 'SEK';
|
|
81
|
+
/**
|
|
82
|
+
* How many years past the current one a sweep reads when a binding names no window.
|
|
83
|
+
*
|
|
84
|
+
* A Swedish maintenance plan is conventionally drawn 30 years out, and almost nothing
|
|
85
|
+
* consumes all of it: the far years are placeholders that move every time a surveyor
|
|
86
|
+
* revisits. Ten years is the horizon a budget actually uses, and — because the window
|
|
87
|
+
* is part of the sync's identity — a shorter one also means the far-future churn does
|
|
88
|
+
* not make every sweep look like a change.
|
|
89
|
+
*/
|
|
90
|
+
export const PLANIMA_DEFAULT_HORIZON_YEARS = 10;
|
|
91
|
+
/** How many action rows ride one `invoke`. */
|
|
92
|
+
const PAGE_SIZE = 500;
|
|
93
|
+
/**
|
|
94
|
+
* One page of a maintenance plan, as it crosses into a scope.
|
|
95
|
+
*
|
|
96
|
+
* Parsed with this schema on the way OUT, before every `invoke`. The engine-seam rule
|
|
97
|
+
* (`returns()`) exists because a value crossing a version boundary must be pinned to a
|
|
98
|
+
* published shape rather than to whatever the code currently produces, and a connector
|
|
99
|
+
* seam is the same boundary with a network in the middle: a vertical compiled against
|
|
100
|
+
* one version of this package and running against another must get a throw, never a
|
|
101
|
+
* silently-reshaped plan on a screen.
|
|
102
|
+
*
|
|
103
|
+
* ## The paging shape, and how a consumer reads it
|
|
104
|
+
*
|
|
105
|
+
* Pages are global across one sync and each page names exactly one facility. A
|
|
106
|
+
* facility's buildings and components ride its **first** page only (`facilityHead`) —
|
|
107
|
+
* they are the same on every page of that facility, and repeating a component list
|
|
108
|
+
* across ten pages of actions is bytes through a clone pipe for nothing. So a consumer
|
|
109
|
+
* upserts on `facilityHead`, appends actions on every page, and commits or swaps when
|
|
110
|
+
* `final` arrives.
|
|
111
|
+
*/
|
|
112
|
+
export const planimaPlanPage = z.object({
|
|
113
|
+
/**
|
|
114
|
+
* Identifies this sync RUN, and it is the content hash rather than a ULID on purpose:
|
|
115
|
+
* two syncs of an unchanged plan produce the same `syncId`, so a consumer's upsert is
|
|
116
|
+
* naturally idempotent and a redelivered page cannot double a cost.
|
|
117
|
+
*/
|
|
118
|
+
syncId: z.string().min(1),
|
|
119
|
+
connectionId: z.string().min(1),
|
|
120
|
+
/** The organization the facility belongs to, when Planima nested one on it. */
|
|
121
|
+
organization: z.object({ id: z.number().int(), name: z.string() }).nullable(),
|
|
122
|
+
/**
|
|
123
|
+
* The facility this page carries — or `null` on a CLEAR page.
|
|
124
|
+
*
|
|
125
|
+
* A sync that finds no facilities at all still has something to say, and saying
|
|
126
|
+
* nothing is the one answer that corrupts a consumer: it commits or swaps on `final`,
|
|
127
|
+
* so a pass that lands zero pages leaves last month's facilities and actions in place
|
|
128
|
+
* for ever, while the cursor records the empty plan as synced and no later sweep
|
|
129
|
+
* repairs it. So an empty plan lands exactly one page — `facility: null`,
|
|
130
|
+
* `actions: []`, `final: true` — which reads as "this plan is now empty" rather than
|
|
131
|
+
* as silence.
|
|
132
|
+
*/
|
|
133
|
+
facility: planimaFacilityFact.nullable(),
|
|
134
|
+
/** The year range the actions on this page were read through — inclusive both ends. */
|
|
135
|
+
window: z.object({ fromYear: z.number().int(), toYear: z.number().int() }),
|
|
136
|
+
/** The currency every `Money` on this page is denominated in; declared, not read — see the binding. */
|
|
137
|
+
currency: z.string(),
|
|
138
|
+
page: z.number().int().nonnegative(),
|
|
139
|
+
pageCount: z.number().int().positive(),
|
|
140
|
+
/** True on the last page of the whole sync — the signal a consumer commits or swaps on. */
|
|
141
|
+
final: z.boolean(),
|
|
142
|
+
/** True on this facility's FIRST page, where its buildings and components ride. */
|
|
143
|
+
facilityHead: z.boolean(),
|
|
144
|
+
buildings: z.array(planimaBuildingFact).default([]),
|
|
145
|
+
components: z.array(planimaComponentFact).default([]),
|
|
146
|
+
actions: z.array(planimaActionFact),
|
|
147
|
+
});
|
|
148
|
+
/**
|
|
149
|
+
* Declare that a connection should sync one scope — the one-time setup a poll-only
|
|
150
|
+
* connector needs in place of a dispatch.
|
|
151
|
+
*
|
|
152
|
+
* **Refuses a binding whose grant is missing**, which is the whole reason this is a
|
|
153
|
+
* function rather than a config object. The alternative — write the binding, discover
|
|
154
|
+
* at sweep time that the connection cannot invoke the operation — fails in the worst
|
|
155
|
+
* possible place: after a whole maintenance plan has been fetched against a
|
|
156
|
+
* 10-request-per-10-seconds budget, in a background timer nobody is watching. Here it
|
|
157
|
+
* fails in the operator's hands, naming the permission to grant.
|
|
158
|
+
*/
|
|
159
|
+
export async function bindPlanimaScope(host, input) {
|
|
160
|
+
const tenant = tenantIdSchema.parse(input.tenantId);
|
|
161
|
+
const scope = scopeIdSchema.parse(input.scopeId);
|
|
162
|
+
if (input.window && input.window.toYear < input.window.fromYear) {
|
|
163
|
+
throw new Error(`window ${input.window.fromYear}..${input.window.toYear} ends before it starts — ` +
|
|
164
|
+
`Planima would return nothing and the sync would look like an empty plan`);
|
|
165
|
+
}
|
|
166
|
+
const horizonYears = input.horizonYears ?? PLANIMA_DEFAULT_HORIZON_YEARS;
|
|
167
|
+
if (!Number.isInteger(horizonYears) || horizonYears < 0) {
|
|
168
|
+
throw new Error(`horizonYears must be a non-negative integer, got ${String(input.horizonYears)}`);
|
|
169
|
+
}
|
|
170
|
+
// On the host, not `admin`: this is the same projection the permission checker reads,
|
|
171
|
+
// so a binding is verified against the tuples that will actually gate the invoke —
|
|
172
|
+
// not against a directory row that may not have reached the scope yet.
|
|
173
|
+
const granted = await host.connectionGrantsInScope(tenant, scope);
|
|
174
|
+
const held = granted.some((g) => g.connectionId === input.connectionId && g.permission === input.permission);
|
|
175
|
+
if (!held) {
|
|
176
|
+
throw new Error(`connection ${input.connectionId} does not hold '${input.permission}' on scope ${input.scopeId} — ` +
|
|
177
|
+
`a sweep would fetch the maintenance plan and then fail to land it. Grant it first ` +
|
|
178
|
+
`(grantToConnection), then bind.`);
|
|
179
|
+
}
|
|
180
|
+
const binding = {
|
|
181
|
+
scopeId: input.scopeId,
|
|
182
|
+
tenantId: input.tenantId,
|
|
183
|
+
vertical: input.vertical,
|
|
184
|
+
operation: input.operation,
|
|
185
|
+
permission: input.permission,
|
|
186
|
+
organizationId: input.organizationId ?? null,
|
|
187
|
+
currency: input.currency ?? PLANIMA_DEFAULT_CURRENCY,
|
|
188
|
+
window: input.window ?? null,
|
|
189
|
+
horizonYears,
|
|
190
|
+
boundAt: new Date(input.now?.() ?? Date.now()).toISOString(),
|
|
191
|
+
};
|
|
192
|
+
await host.admin.putConnectorState(input.connectionId, bindingKey(input.scopeId), binding);
|
|
193
|
+
return binding;
|
|
194
|
+
}
|
|
195
|
+
/** Every scope this connection syncs into. */
|
|
196
|
+
export async function listPlanimaBindings(host, connectionId) {
|
|
197
|
+
const rows = await host.admin.listConnectorState(connectionId, BINDING_PREFIX);
|
|
198
|
+
// Tombstones filtered, exactly as the sweep and the activity projection do.
|
|
199
|
+
// `unbindPlanimaScope` writes `null` under the same key, and casting that to
|
|
200
|
+
// `PlanimaBinding` hands a caller a typed value that throws on the first property
|
|
201
|
+
// read — a lie the type system cannot catch.
|
|
202
|
+
return rows
|
|
203
|
+
.filter((r) => r.value !== null && typeof r.value === 'object')
|
|
204
|
+
.map((r) => r.value);
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Stop syncing one scope. The binding row is replaced with a tombstone rather than
|
|
208
|
+
* removed, because `putConnectorState` is the only verb this surface has — and an
|
|
209
|
+
* unbound scope that a later sweep silently re-adopts would be worse than a visible
|
|
210
|
+
* dead row.
|
|
211
|
+
*/
|
|
212
|
+
export async function unbindPlanimaScope(host, connectionId, scopeId) {
|
|
213
|
+
await host.admin.putConnectorState(connectionId, bindingKey(scopeId), null);
|
|
214
|
+
}
|
|
215
|
+
const sha256Hex = async (text) => {
|
|
216
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
|
|
217
|
+
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
218
|
+
};
|
|
219
|
+
/** The window a binding reads through, resolved against the clock for a rolling one. */
|
|
220
|
+
export function windowFor(binding, nowMs) {
|
|
221
|
+
if (binding.window)
|
|
222
|
+
return binding.window;
|
|
223
|
+
const fromYear = new Date(nowMs).getUTCFullYear();
|
|
224
|
+
return { fromYear, toYear: fromYear + binding.horizonYears };
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Sync ONE bound scope: read the plan, hash it, and land it through the binding's
|
|
228
|
+
* operation as the connection itself (#97).
|
|
229
|
+
*
|
|
230
|
+
* Idempotent and cheap to re-land on a no-op. The assembled plan is hashed before
|
|
231
|
+
* anything is landed, and an unchanged hash returns `changed: false` without a single
|
|
232
|
+
* `invoke` — which matters because a sweep runs on a timer and most passes find a plan
|
|
233
|
+
* nobody has touched.
|
|
234
|
+
*
|
|
235
|
+
* Note what that does NOT save: the provider reads still happen, because Planima offers
|
|
236
|
+
* no collection-level `updated_at` or ETag to ask "has anything changed" cheaply. The
|
|
237
|
+
* skip saves the writes and the events, not the round trips. Being straight about that
|
|
238
|
+
* is what makes the sweep interval a real decision — every pass costs
|
|
239
|
+
* `1 + 3 × facilities` requests against a 10-per-10-second budget.
|
|
240
|
+
*/
|
|
241
|
+
export async function syncPlanimaScope(host, connectionId, binding, options) {
|
|
242
|
+
const admin = host.admin;
|
|
243
|
+
const conn = await openPlanimaConnection(admin, options.fetch, binding.tenantId, binding.vertical, options.timeoutMs ?? 30_000,
|
|
244
|
+
// The binding's own connection, or nothing. See `openPlanimaConnection`.
|
|
245
|
+
connectionId);
|
|
246
|
+
const api = new PlanimaApi(conn, {
|
|
247
|
+
apiBase: options.apiBase,
|
|
248
|
+
now: options.now,
|
|
249
|
+
sleep: options.sleep,
|
|
250
|
+
rateWindow: options.rateWindow,
|
|
251
|
+
});
|
|
252
|
+
const nowMs = options.now?.() ?? Date.now();
|
|
253
|
+
const window = options.window ?? windowFor(binding, nowMs);
|
|
254
|
+
const currency = binding.currency || PLANIMA_DEFAULT_CURRENCY;
|
|
255
|
+
const facilities = await api.facilities(binding.organizationId ?? undefined);
|
|
256
|
+
// Sorted by id before anything else touches them. Planima documents no ordering, so
|
|
257
|
+
// two sweeps could legitimately return the same facilities in a different order — and
|
|
258
|
+
// an unsorted hash would then read as a changed plan and re-land the whole thing.
|
|
259
|
+
// The same reasoning applies to every list below.
|
|
260
|
+
facilities.sort((a, b) => a.id - b.id);
|
|
261
|
+
const plans = [];
|
|
262
|
+
for (const facility of facilities) {
|
|
263
|
+
// Sequential, not `Promise.all`: the client's rate-limit window is a per-instance
|
|
264
|
+
// list, so three concurrent requests would each see an empty window and fire at
|
|
265
|
+
// once. Against a 10-per-10-second budget that is how a sweep of a handful of
|
|
266
|
+
// facilities starts eating 429s.
|
|
267
|
+
const buildings = await api.buildings(facility.id);
|
|
268
|
+
const components = await api.components(facility.id);
|
|
269
|
+
const actions = await api.actions(facility.id, window);
|
|
270
|
+
buildings.sort((a, b) => a.id - b.id);
|
|
271
|
+
components.sort((a, b) => a.id - b.id);
|
|
272
|
+
actions.sort((a, b) => a.id - b.id);
|
|
273
|
+
plans.push({
|
|
274
|
+
facility: facilityFact(facility),
|
|
275
|
+
organization: facility.organization
|
|
276
|
+
? { id: facility.organization.id, name: facility.organization.name }
|
|
277
|
+
: null,
|
|
278
|
+
buildings: buildings.map(buildingFact),
|
|
279
|
+
components: components.map(componentFact),
|
|
280
|
+
actions: actions.map((a) => actionFactIn(a, facility.id, currency)),
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
// The WINDOW and the CURRENCY are part of the sync's identity, not just the rows.
|
|
284
|
+
//
|
|
285
|
+
// The window because what lands is the plan filtered to it (Fortnox's lesson, and
|
|
286
|
+
// sharper here because a rolling window moves by itself every January). The currency
|
|
287
|
+
// because it is a declared value rather than a read one: re-binding a scope from SEK
|
|
288
|
+
// to EUR changes every `Money` that lands while leaving every provider row identical,
|
|
289
|
+
// and a hash over the rows alone would call that no change at all.
|
|
290
|
+
const contentHash = await sha256Hex(JSON.stringify({ window, currency, organizationId: binding.organizationId, plans }));
|
|
291
|
+
const actionCount = plans.reduce((n, p) => n + p.actions.length, 0);
|
|
292
|
+
if (binding.lastSync?.contentHash === contentHash) {
|
|
293
|
+
return {
|
|
294
|
+
scopeId: binding.scopeId,
|
|
295
|
+
changed: false,
|
|
296
|
+
syncId: contentHash,
|
|
297
|
+
window,
|
|
298
|
+
facilities: plans.length,
|
|
299
|
+
actions: actionCount,
|
|
300
|
+
pages: 0,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
// Every page of every facility, counted BEFORE the first invoke, because `pageCount`
|
|
304
|
+
// and `final` are on page 0 and a consumer swapping a plan atomically needs to know
|
|
305
|
+
// from the first page how many are coming.
|
|
306
|
+
//
|
|
307
|
+
// Floored at one: a plan with no facilities still lands a single CLEAR page. Landing
|
|
308
|
+
// nothing would leave a consumer holding the previous sync's rows for ever, because
|
|
309
|
+
// it swaps on `final` and no `final` would ever arrive — while the cursor below
|
|
310
|
+
// recorded the empty plan as synced, so no later sweep would repair it either.
|
|
311
|
+
const pageCount = Math.max(1, plans.reduce((n, p) => n + Math.max(1, Math.ceil(p.actions.length / PAGE_SIZE)), 0));
|
|
312
|
+
// The connection acting as itself (#97). Refuses a scope in another tenant or running
|
|
313
|
+
// another vertical by construction, and the invoke below is gated on the connection's
|
|
314
|
+
// own grant — the one `bindPlanimaScope` verified.
|
|
315
|
+
const scope = await host.getConnectorScope(connectionId, scopeIdSchema.parse(binding.scopeId));
|
|
316
|
+
if (plans.length === 0) {
|
|
317
|
+
// "This plan is now empty", said explicitly. Every facility deleted, an
|
|
318
|
+
// organization filter that matches nothing, a token whose access was narrowed —
|
|
319
|
+
// all of them arrive here, and all of them are a fact the consumer needs.
|
|
320
|
+
await scope.invoke(binding.operation, planimaPlanPage.parse({
|
|
321
|
+
syncId: contentHash,
|
|
322
|
+
connectionId,
|
|
323
|
+
organization: null,
|
|
324
|
+
facility: null,
|
|
325
|
+
window,
|
|
326
|
+
currency,
|
|
327
|
+
page: 0,
|
|
328
|
+
pageCount: 1,
|
|
329
|
+
final: true,
|
|
330
|
+
facilityHead: false,
|
|
331
|
+
buildings: [],
|
|
332
|
+
components: [],
|
|
333
|
+
actions: [],
|
|
334
|
+
}));
|
|
335
|
+
}
|
|
336
|
+
let page = 0;
|
|
337
|
+
for (const plan of plans) {
|
|
338
|
+
const facilityPages = Math.max(1, Math.ceil(plan.actions.length / PAGE_SIZE));
|
|
339
|
+
for (let i = 0; i < facilityPages; i += 1) {
|
|
340
|
+
const payload = planimaPlanPage.parse({
|
|
341
|
+
syncId: contentHash,
|
|
342
|
+
connectionId,
|
|
343
|
+
organization: plan.organization,
|
|
344
|
+
facility: plan.facility,
|
|
345
|
+
window,
|
|
346
|
+
currency,
|
|
347
|
+
page,
|
|
348
|
+
pageCount,
|
|
349
|
+
final: page === pageCount - 1,
|
|
350
|
+
facilityHead: i === 0,
|
|
351
|
+
buildings: i === 0 ? plan.buildings : [],
|
|
352
|
+
components: i === 0 ? plan.components : [],
|
|
353
|
+
actions: plan.actions.slice(i * PAGE_SIZE, (i + 1) * PAGE_SIZE),
|
|
354
|
+
});
|
|
355
|
+
await scope.invoke(binding.operation, payload);
|
|
356
|
+
page += 1;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
const synced = {
|
|
360
|
+
...binding,
|
|
361
|
+
lastSync: {
|
|
362
|
+
syncedAt: new Date(nowMs).toISOString(),
|
|
363
|
+
contentHash,
|
|
364
|
+
facilities: plans.length,
|
|
365
|
+
actions: actionCount,
|
|
366
|
+
},
|
|
367
|
+
};
|
|
368
|
+
// Written only AFTER every page landed, so a failure mid-way leaves the cursor at the
|
|
369
|
+
// previous hash and the next sweep retries the whole plan rather than resuming into a
|
|
370
|
+
// half-written one.
|
|
371
|
+
await admin.putConnectorState(connectionId, bindingKey(binding.scopeId), synced);
|
|
372
|
+
return {
|
|
373
|
+
scopeId: binding.scopeId,
|
|
374
|
+
changed: true,
|
|
375
|
+
syncId: contentHash,
|
|
376
|
+
window,
|
|
377
|
+
facilities: plans.length,
|
|
378
|
+
actions: actionCount,
|
|
379
|
+
pages: pageCount,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Poll Planima for every scope this connection is bound to — the sweeper a deployment
|
|
384
|
+
* schedules.
|
|
385
|
+
*
|
|
386
|
+
* A timer calls this; it holds no timer itself. That keeps the trigger a deployment
|
|
387
|
+
* concern (`startPlatformSweeper` on node, `definePlatformSweeperDO`'s alarm on
|
|
388
|
+
* Cloudflare) and this a plain, testable function.
|
|
389
|
+
*
|
|
390
|
+
* Robust the way a poller must be: an unchanged plan is skipped without landing
|
|
391
|
+
* anything, and a failure on one scope is recorded and stepped over rather than sinking
|
|
392
|
+
* the pass — one vertical's missing grant must not stop another tenant's plan syncing.
|
|
393
|
+
*/
|
|
394
|
+
export async function sweepPlanimaPlan(host, connectionId, options) {
|
|
395
|
+
const rows = await host.admin.listConnectorState(connectionId, BINDING_PREFIX);
|
|
396
|
+
const result = { found: 0, synced: [], unchanged: 0, failed: [] };
|
|
397
|
+
// ONE rate-limit window for the whole pass. Every binding under this connection
|
|
398
|
+
// shares its token, and Planima meters per token — so a per-scope window would let
|
|
399
|
+
// the second scope's first ten requests land on top of the first scope's ten.
|
|
400
|
+
const rateWindow = options.rateWindow ?? [];
|
|
401
|
+
for (const { value } of rows) {
|
|
402
|
+
// A tombstoned binding (`unbindPlanimaScope`) — present as a row, not a target.
|
|
403
|
+
if (value === null || typeof value !== 'object')
|
|
404
|
+
continue;
|
|
405
|
+
const binding = value;
|
|
406
|
+
result.found += 1;
|
|
407
|
+
try {
|
|
408
|
+
const r = await syncPlanimaScope(host, connectionId, binding, { ...options, rateWindow });
|
|
409
|
+
if (r.changed)
|
|
410
|
+
result.synced.push(r);
|
|
411
|
+
else
|
|
412
|
+
result.unchanged += 1;
|
|
413
|
+
}
|
|
414
|
+
catch (err) {
|
|
415
|
+
result.failed.push({
|
|
416
|
+
scopeId: binding.scopeId,
|
|
417
|
+
error: err instanceof Error ? err.message : String(err),
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return result;
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Probe a credential that is not stored yet — the connect-time check (#605).
|
|
425
|
+
*
|
|
426
|
+
* Takes the candidate secret directly, touches no connection and no store, and records
|
|
427
|
+
* no health: there may be no connection to record against, and a candidate's failure is
|
|
428
|
+
* not a fact about a live one.
|
|
429
|
+
*
|
|
430
|
+
* The probe reads `/organizations`, which is both the cheapest authenticated read
|
|
431
|
+
* Planima offers and the one that answers the question an operator actually has: not
|
|
432
|
+
* "is this token valid" but "does this token see the customer account I meant". A token
|
|
433
|
+
* from the wrong Planima login is perfectly valid and syncs somebody else's buildings.
|
|
434
|
+
*/
|
|
435
|
+
export async function probePlanimaSecret(secret, options) {
|
|
436
|
+
const parsed = planimaSecret.safeParse(secret);
|
|
437
|
+
if (!parsed.success) {
|
|
438
|
+
// A malformed credential IS a refusal — Planima would reject it, and there is no
|
|
439
|
+
// point spending a round trip to hear so.
|
|
440
|
+
return {
|
|
441
|
+
ok: false,
|
|
442
|
+
refused: true,
|
|
443
|
+
accountRef: null,
|
|
444
|
+
accountLabel: null,
|
|
445
|
+
facts: [],
|
|
446
|
+
error: `incomplete Planima credential: ${parsed.error.issues
|
|
447
|
+
.map((i) => i.path.join('.'))
|
|
448
|
+
.join(', ')}`,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
const timeoutMs = options.timeoutMs ?? 15_000;
|
|
452
|
+
const conn = {
|
|
453
|
+
id: connectionIdSchema.parse('00000000000000000000000000'), // no row yet; never read
|
|
454
|
+
tenantId: '',
|
|
455
|
+
vertical: '',
|
|
456
|
+
provider: 'planima',
|
|
457
|
+
secret: parsed.data,
|
|
458
|
+
expiresAt: null,
|
|
459
|
+
fetch: (input, init) => options.fetch(input, { ...init, signal: AbortSignal.timeout(timeoutMs) }),
|
|
460
|
+
};
|
|
461
|
+
return probeWith(conn, options);
|
|
462
|
+
}
|
|
463
|
+
/** Probe the credential a live connection already holds. Verifying is itself a use. */
|
|
464
|
+
export async function probePlanimaConnection(host, connection, options) {
|
|
465
|
+
const conn = await openPlanimaConnection(host.admin, options.fetch, connection.tenantId, connection.vertical, options.timeoutMs ?? 15_000);
|
|
466
|
+
return probeWith(conn, options);
|
|
467
|
+
}
|
|
468
|
+
async function probeWith(conn, options) {
|
|
469
|
+
const api = new PlanimaApi(conn, {
|
|
470
|
+
apiBase: options.apiBase,
|
|
471
|
+
now: options.now,
|
|
472
|
+
sleep: options.sleep,
|
|
473
|
+
// A probe answers a person waiting on a form. Sitting out a rate limit for tens of
|
|
474
|
+
// seconds to answer them is worse than saying "busy, try again" — and a 429 is not
|
|
475
|
+
// a fact about the credential, which is what a probe is for.
|
|
476
|
+
maxRateLimitRetries: 0,
|
|
477
|
+
});
|
|
478
|
+
try {
|
|
479
|
+
const organizations = await api.organizations();
|
|
480
|
+
return {
|
|
481
|
+
ok: true,
|
|
482
|
+
refused: false,
|
|
483
|
+
// Left null on purpose: a Planima token is one customer ACCOUNT's, and an account
|
|
484
|
+
// may hold several organizations, so no single id names what this credential
|
|
485
|
+
// reads. The dashboard catalog therefore declares no `accountRefField` and a
|
|
486
|
+
// re-paste rotates the one connection in place, which is the right behaviour when
|
|
487
|
+
// there is only ever one.
|
|
488
|
+
accountRef: null,
|
|
489
|
+
accountLabel: organizations.length === 1
|
|
490
|
+
? (organizations[0]?.name ?? null)
|
|
491
|
+
: organizations.length === 0
|
|
492
|
+
? null
|
|
493
|
+
: `${organizations.length} organizations`,
|
|
494
|
+
facts: [
|
|
495
|
+
{ label: 'Organizations', value: String(organizations.length) },
|
|
496
|
+
{
|
|
497
|
+
label: 'Names',
|
|
498
|
+
value: organizations.length === 0
|
|
499
|
+
? '— (the token is valid but sees nothing)'
|
|
500
|
+
: organizations
|
|
501
|
+
.slice(0, 5)
|
|
502
|
+
.map((o) => o.name)
|
|
503
|
+
.join(', ') + (organizations.length > 5 ? `, +${organizations.length - 5} more` : ''),
|
|
504
|
+
},
|
|
505
|
+
],
|
|
506
|
+
error: null,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
catch (err) {
|
|
510
|
+
return {
|
|
511
|
+
ok: false,
|
|
512
|
+
// Only "not with this token" counts. A timeout, a 429 or a 5xx says nothing about
|
|
513
|
+
// the credential, and treating it as a refusal would make a Planima outage look
|
|
514
|
+
// like every tenant's token going bad at once.
|
|
515
|
+
refused: err instanceof PlanimaApiError && err.refused,
|
|
516
|
+
accountRef: null,
|
|
517
|
+
accountLabel: null,
|
|
518
|
+
facts: [],
|
|
519
|
+
error: err instanceof Error ? err.message : String(err),
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* What this connection has been doing, for a console — one entry per bound scope.
|
|
525
|
+
*
|
|
526
|
+
* Reads the binding ledger rather than the provider: this answers "what has the platform
|
|
527
|
+
* synced", which is the question an operator asks when a plan looks stale, and it
|
|
528
|
+
* answers it without spending a provider round trip against a 10-per-10-second budget.
|
|
529
|
+
*/
|
|
530
|
+
export async function planimaConnectionActivity(host, connectionId) {
|
|
531
|
+
const rows = await host.admin.listConnectorState(connectionId, BINDING_PREFIX);
|
|
532
|
+
const entries = rows
|
|
533
|
+
.filter(({ value }) => value !== null && typeof value === 'object')
|
|
534
|
+
.map(({ key, value }) => {
|
|
535
|
+
const b = value;
|
|
536
|
+
return {
|
|
537
|
+
key,
|
|
538
|
+
title: `${b.vertical} — ${b.scopeId}`,
|
|
539
|
+
reference: b.organizationId === null ? null : String(b.organizationId),
|
|
540
|
+
status: b.lastSync ? 'synced' : 'bound — not yet synced',
|
|
541
|
+
at: instant.parse(b.lastSync?.syncedAt ?? b.boundAt),
|
|
542
|
+
facts: [
|
|
543
|
+
{ label: 'Lands through', value: b.operation },
|
|
544
|
+
{ label: 'Permission', value: b.permission },
|
|
545
|
+
{
|
|
546
|
+
label: 'Organization',
|
|
547
|
+
value: b.organizationId === null ? 'all the token can see' : String(b.organizationId),
|
|
548
|
+
},
|
|
549
|
+
{
|
|
550
|
+
label: 'Window',
|
|
551
|
+
value: b.window
|
|
552
|
+
? `${b.window.fromYear}–${b.window.toYear} (fixed)`
|
|
553
|
+
: `rolling, +${b.horizonYears} years`,
|
|
554
|
+
},
|
|
555
|
+
...(b.lastSync
|
|
556
|
+
? [
|
|
557
|
+
{ label: 'Facilities', value: String(b.lastSync.facilities) },
|
|
558
|
+
{ label: 'Actions', value: String(b.lastSync.actions) },
|
|
559
|
+
// The content hash IS the sync identity, so an operator comparing two
|
|
560
|
+
// scopes can tell "same plan" from "same moment" at a glance.
|
|
561
|
+
{ label: 'Content hash', value: b.lastSync.contentHash.slice(0, 12) },
|
|
562
|
+
]
|
|
563
|
+
: []),
|
|
564
|
+
],
|
|
565
|
+
};
|
|
566
|
+
});
|
|
567
|
+
return connectionActivity.parse({
|
|
568
|
+
source: 'ledger',
|
|
569
|
+
entries,
|
|
570
|
+
// Never live: this reads the binding ledger, never the provider. The ledger knows
|
|
571
|
+
// what the platform synced, not what Planima has since changed, and a console that
|
|
572
|
+
// blurs the two invents facts.
|
|
573
|
+
live: false,
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* The stored credential, REDUCED (#605) — and for this provider that is one masked
|
|
578
|
+
* field and nothing else.
|
|
579
|
+
*
|
|
580
|
+
* There is no identifier half to show. Fortnox can display its client id and
|
|
581
|
+
* DatabaseNumber unmasked because they name the integration and the company; a Planima
|
|
582
|
+
* credential is a single opaque token and every character of it is secret. So this
|
|
583
|
+
* surface is honest rather than useful, and the useful answer — *which* Planima account
|
|
584
|
+
* this is — comes from {@link probePlanimaConnection}, which reads the organization
|
|
585
|
+
* names back from the provider.
|
|
586
|
+
*/
|
|
587
|
+
export async function planimaCredentialSummary(host, connection) {
|
|
588
|
+
const open = await host.admin.openConnection(tenantIdSchema.parse(connection.tenantId), connection.vertical, 'planima');
|
|
589
|
+
if (!open) {
|
|
590
|
+
throw new Error(`no live 'planima' connection for tenant ${connection.tenantId} / vertical '${connection.vertical}'`);
|
|
591
|
+
}
|
|
592
|
+
const secret = planimaSecret.parse(open.secret);
|
|
593
|
+
return {
|
|
594
|
+
fields: [{ key: 'token', label: 'API token', value: maskSecret(secret.token), masked: true }],
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
/** A bullet run plus the last four — or nothing at all when there is too little to hide behind. */
|
|
598
|
+
const maskSecret = (value) => value.length < 8 ? '••••••••' : `••••••••${value.slice(-4)}`;
|
|
599
|
+
/** Open the live Planima connection for a (tenant, vertical), with health recorded. */
|
|
600
|
+
async function openPlanimaConnection(admin, fetchImpl, tenant, vertical, timeoutMs, expected) {
|
|
601
|
+
const parsedTenant = tenantIdSchema.parse(tenant);
|
|
602
|
+
const open = await admin.openConnection(parsedTenant, vertical, 'planima');
|
|
603
|
+
if (!open) {
|
|
604
|
+
throw new Error(`no live 'planima' connection for tenant ${tenant} / vertical '${vertical}'`);
|
|
605
|
+
}
|
|
606
|
+
// The credential that READS and the identity that WRITES must be the same connection.
|
|
607
|
+
//
|
|
608
|
+
// A binding names a `connectionId`, and that id is what opens the scope, stamps the
|
|
609
|
+
// spine and is checked for the grant. The token, though, comes from
|
|
610
|
+
// `openConnection(tenant, vertical, provider)` — whichever live row exists. Nothing in
|
|
611
|
+
// THIS file makes those the same row; two layers below it do, and both were checked
|
|
612
|
+
// rather than assumed: the directory holds a UNIQUE constraint that refuses a second
|
|
613
|
+
// live connection for one (tenant, vertical, provider, account), and revoking one
|
|
614
|
+
// takes its bindings with it. So a mismatch is currently unreachable.
|
|
615
|
+
//
|
|
616
|
+
// This is a backstop for the day that stops being true, not a fix for a live bug. It
|
|
617
|
+
// is here because the invariant is load-bearing and invisible: if Planima ever gains
|
|
618
|
+
// an `accountRefField` — one token per client company, the shape Fortnox already has —
|
|
619
|
+
// a (tenant, vertical) grows several live connections, `openConnection` starts
|
|
620
|
+
// choosing between them, and the sweep would silently read one company's plan with
|
|
621
|
+
// another's credential while the audit trail named a connection that fetched nothing.
|
|
622
|
+
// One comparison buys a loud refusal instead of that.
|
|
623
|
+
if (expected !== undefined && open.id !== expected) {
|
|
624
|
+
throw new Error(`binding names connection ${expected}, but the live 'planima' connection for tenant ` +
|
|
625
|
+
`${tenant} / vertical '${vertical}' is ${open.id} — the credential that reads and the ` +
|
|
626
|
+
`identity that writes must be the same connection. Re-bind the scope against ${open.id}.`);
|
|
627
|
+
}
|
|
628
|
+
return {
|
|
629
|
+
...open,
|
|
630
|
+
fetch: async (input, init) => {
|
|
631
|
+
try {
|
|
632
|
+
const res = await fetchImpl(input, { ...init, signal: AbortSignal.timeout(timeoutMs) });
|
|
633
|
+
await admin.recordConnectionUse(open.id, res.ok ? { ok: true } : { ok: false, error: `HTTP ${res.status} from planima` });
|
|
634
|
+
return res;
|
|
635
|
+
}
|
|
636
|
+
catch (err) {
|
|
637
|
+
await admin.recordConnectionUse(open.id, {
|
|
638
|
+
ok: false,
|
|
639
|
+
error: err instanceof Error ? err.message : String(err),
|
|
640
|
+
});
|
|
641
|
+
throw err;
|
|
642
|
+
}
|
|
643
|
+
},
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
//# sourceMappingURL=index.js.map
|