@arcforgelabs/flow 2026.11.6 → 2026.11.7
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/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/app.js +1 -1
- package/server/core/sync-now.js +38 -0
- package/server/integrations/port.js +2 -0
- package/server/integrations/seazona/index.js +44 -0
- package/server/integrations/seazona/scheduler.js +13 -2
- package/server/integrations/seazona/settings.js +3 -0
- package/server/integrations/seazona/sweep.js +10 -3
- package/server/integrations/setup.js +21 -1
- package/server/integrations/xero/index.js +25 -0
- package/server/integrations/xero/settings.js +3 -0
- package/server/integrations/xero/webhooks.js +4 -3
- package/server/ui/app.js +64 -3
- package/server/ui/flow.css +2 -1
- package/server/ui/kit.js +3 -2
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcforgelabs/flow",
|
|
3
|
-
"version": "2026.11.
|
|
3
|
+
"version": "2026.11.7",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@arcforgelabs/flow",
|
|
9
|
-
"version": "2026.11.
|
|
9
|
+
"version": "2026.11.7",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@clerk/backend": "3.25.0",
|
package/package.json
CHANGED
package/server/app.js
CHANGED
|
@@ -146,7 +146,7 @@ export function createApp({
|
|
|
146
146
|
// Settings, Integrations: an owner turns integrations on and enters their
|
|
147
147
|
// settings (server/integrations/setup.js).
|
|
148
148
|
if (integrations && database && auth?.personAuth) {
|
|
149
|
-
app.use('/api/integrations', createSetupRouter({ db: database.db, registry: integrations, personAuth: auth.personAuth, secrets, log }));
|
|
149
|
+
app.use('/api/integrations', createSetupRouter({ db: database.db, registry: integrations, personAuth: auth.personAuth, secrets, log, buttonCooldowns }));
|
|
150
150
|
}
|
|
151
151
|
// Invoicing for signed-in people (server/api/invoicing.js). A job's draft
|
|
152
152
|
// invoice needs a live check in the accounting system: the first enabled
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Sync now (docs/INTEGRATIONS.md, "Sync now"): a person asks Flow to read its
|
|
2
|
+
// systems again now, not at the next background run. An integration takes
|
|
3
|
+
// part while its own "Include in Sync now" setting is on, the default. Each
|
|
4
|
+
// integration's page also has its own Sync, which reads that one integration
|
|
5
|
+
// whatever the setting says. Each read goes through the integration's call guard
|
|
6
|
+
// and rate limit, as a background read does. The pipeline joins what they
|
|
7
|
+
// bring into jobs at its next run (every 2 minutes).
|
|
8
|
+
|
|
9
|
+
const named = (adapter, answer) => ({ id: adapter.id, name: adapter.name, ...answer });
|
|
10
|
+
|
|
11
|
+
// What the Sync now button shows: each integration that can take part,
|
|
12
|
+
// whether it does, whether a read is going, and when the last one started.
|
|
13
|
+
// Reads Flow's data only.
|
|
14
|
+
export async function syncNowStatus(registry) {
|
|
15
|
+
return Promise.all(registry.with('syncNowStatus').map(async (adapter) => {
|
|
16
|
+
try {
|
|
17
|
+
return named(adapter, await adapter.capabilities.syncNowStatus());
|
|
18
|
+
} catch {
|
|
19
|
+
return named(adapter, { included: false, running: false, lastAt: null, ok: null });
|
|
20
|
+
}
|
|
21
|
+
}));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Starts each included integration's read, one after the other, or only the
|
|
25
|
+
// integration `only` names. An integration that cannot read answers
|
|
26
|
+
// `unavailable` with its reason; the others still run.
|
|
27
|
+
export async function startSyncNow(registry, { only = null, actor = null, log = console } = {}) {
|
|
28
|
+
const results = [];
|
|
29
|
+
for (const adapter of registry.with('syncNow').filter((a) => !only || a.id === only)) {
|
|
30
|
+
try {
|
|
31
|
+
results.push(named(adapter, await adapter.capabilities.syncNow({ asked: Boolean(only) })));
|
|
32
|
+
} catch (error) {
|
|
33
|
+
results.push(named(adapter, { state: 'unavailable', reason: error?.message || 'the read failed' }));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
log.info?.(`[sync-now] person ${actor ?? 'unknown'}: ${results.map((r) => `${r.id} ${r.state}`).join(', ') || 'no integration can sync'}`);
|
|
37
|
+
return results;
|
|
38
|
+
}
|
|
@@ -39,6 +39,8 @@ export const CAPABILITIES = Object.freeze([
|
|
|
39
39
|
'readChanges', // { state: fresh | read | unavailable }: the accounting system's latest changes into its copy now, just before Flow drafts or sends (read before act)
|
|
40
40
|
'itemMatches', // { organisationId, codes } -> the Flow price codes that send as an item the system sells (Xero: linked item, else same code), from its copy; null while not connected or its item list was never read; never a live call
|
|
41
41
|
'listPriceItems', // the system's own price list from its copy in Flow, for the price list's links and check; never a live call
|
|
42
|
+
'syncNow', // { asked } -> { state: started | running | read | off | unavailable, reason?, changed? }: a person's Sync now; read the system's latest changes into its copy now, inside its call guard. off: its "Include in Sync now" setting is off and `asked` (a person's Sync for this integration alone) is false (server/core/sync-now.js)
|
|
43
|
+
'syncNowStatus', // {} -> { included, running, lastAt, ok }: for the Sync now button; reads Flow's data only
|
|
42
44
|
'health', // connection state for Settings
|
|
43
45
|
'alerts', // what only a person can clear (a refused sign-in, stopped calls), for the Integrations page; reads Flow's data only
|
|
44
46
|
'captureError', // an error Flow logged, sent to the installation's own error service (Sentry)
|
|
@@ -53,10 +53,15 @@ export const SEAZONA_SETUP = {
|
|
|
53
53
|
{ name: 'SEAZONA_API_CLIENT_SECRET', label: 'Client secret', secret: true, help: 'Stored encrypted. Flow never shows it again.' },
|
|
54
54
|
{ name: 'SEAZONA_ORDER_URL', label: 'Order address', kind: 'url', help: 'Optional. Open any order in Seazona and paste its address here, or write {ref} where the order id goes. Stage moves on the Jobs board and Open in Seazona use it.' },
|
|
55
55
|
{ name: 'SEAZONA_KEEP_PATIENT_REFERENCE', label: 'Keep patient names', kind: 'onoff', help: 'On: Flow keeps the patient name of each job for the invoice Reference.' },
|
|
56
|
+
{ name: 'SEAZONA_SYNC_NOW', label: 'Include in Sync now', kind: 'onoff', default: 'on', help: 'On: Sync now on the Integrations page reads Seazona\'s new and changed orders, invoices and practices.' },
|
|
56
57
|
{ name: 'SEAZONA_API_READ_HIDE_PATIENT', label: 'Hide patient details', kind: 'onoff', help: 'On: an AI assistant reading Seazona through Flow gets no patient, note, file or clinical fields.' },
|
|
57
58
|
],
|
|
58
59
|
};
|
|
59
60
|
|
|
61
|
+
// The lanes Sync now reads: what changes from day to day. Products, lab
|
|
62
|
+
// users, the history read and the sweep keep their own schedule.
|
|
63
|
+
export const SYNC_NOW_LANES = Object.freeze(['invoices', 'orders_recent', 'orders_active', 'clients']);
|
|
64
|
+
|
|
60
65
|
// What a person reads for each lane: the Seazona records it copies.
|
|
61
66
|
const READ_NAMES = {
|
|
62
67
|
invoices: 'invoices',
|
|
@@ -72,6 +77,10 @@ const readNames = (lanes) => lanes.map((lane) => READ_NAMES[lane.lane] ?? lane.l
|
|
|
72
77
|
export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.now, timers } = {}) {
|
|
73
78
|
let runtime = null; // { settings, db, client, scheduler }
|
|
74
79
|
const actReader = createActReader({ now });
|
|
80
|
+
// Sync now with no background sync (sandbox, replay, or background jobs
|
|
81
|
+
// off): one run at a time, and when the last one ended.
|
|
82
|
+
let manual = null;
|
|
83
|
+
let manualLast = null;
|
|
75
84
|
|
|
76
85
|
function current() {
|
|
77
86
|
if (!runtime) throw new Error('The Seazona adapter has not started');
|
|
@@ -121,6 +130,7 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
|
|
|
121
130
|
|
|
122
131
|
async stop() {
|
|
123
132
|
await runtime?.scheduler?.stop();
|
|
133
|
+
await manual;
|
|
124
134
|
runtime = null;
|
|
125
135
|
},
|
|
126
136
|
|
|
@@ -178,6 +188,40 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
|
|
|
178
188
|
return alerts;
|
|
179
189
|
},
|
|
180
190
|
|
|
191
|
+
// Sync now (server/core/sync-now.js): read new and changed orders,
|
|
192
|
+
// invoices and practices now, inside the rate governor. It answers at
|
|
193
|
+
// once; the run goes on in the background. Off while "Include in Sync
|
|
194
|
+
// now" is off, unless a person asked for Seazona alone (`asked`, its own
|
|
195
|
+
// Sync button).
|
|
196
|
+
async syncNow({ asked = false } = {}) {
|
|
197
|
+
if (!runtime) return { state: 'unavailable', reason: 'Seazona has not started' };
|
|
198
|
+
if (!runtime.settings.syncNow && !asked) return { state: 'off' };
|
|
199
|
+
const options = { only: SYNC_NOW_LANES };
|
|
200
|
+
if (runtime.scheduler) {
|
|
201
|
+
const state = runtime.scheduler.runNow(options);
|
|
202
|
+
return state === 'stopped' ? { state: 'unavailable', reason: 'Seazona is stopping' } : { state };
|
|
203
|
+
}
|
|
204
|
+
if (manual) return { state: 'running' };
|
|
205
|
+
const at = new Date(Number(now())).toISOString();
|
|
206
|
+
manual = runtime.run(options)
|
|
207
|
+
.then((result) => { manualLast = { at, ok: result.ok }; })
|
|
208
|
+
.catch(() => { manualLast = { at, ok: false }; })
|
|
209
|
+
.finally(() => { manual = null; });
|
|
210
|
+
return { state: 'started' };
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
async syncNowStatus() {
|
|
214
|
+
if (!runtime) return { included: false, running: false, lastAt: null, ok: null };
|
|
215
|
+
const scheduled = runtime.scheduler?.status();
|
|
216
|
+
const last = scheduled ? scheduled.lastRun : manualLast;
|
|
217
|
+
return {
|
|
218
|
+
included: runtime.settings.syncNow,
|
|
219
|
+
running: Boolean(scheduled?.running || manual),
|
|
220
|
+
lastAt: last?.at ?? null,
|
|
221
|
+
ok: last ? last.ok : null,
|
|
222
|
+
};
|
|
223
|
+
},
|
|
224
|
+
|
|
181
225
|
// Billable work since a cursor: complete and shipped orders that
|
|
182
226
|
// changed, with the Seazona invoice that carries each one. Orders
|
|
183
227
|
// deleted in Seazona are never billable; they come back in `withdrawn`
|
|
@@ -85,14 +85,14 @@ export function createSeazonaScheduler({
|
|
|
85
85
|
timer?.unref?.();
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
async function tick() {
|
|
88
|
+
async function tick(options = {}) {
|
|
89
89
|
timer = null;
|
|
90
90
|
if (stopped) return;
|
|
91
91
|
const startedAt = new Date(Number(now())).toISOString();
|
|
92
92
|
const turn = { abandoned: false };
|
|
93
93
|
const thisRun = (async () => {
|
|
94
94
|
try {
|
|
95
|
-
const result = await run();
|
|
95
|
+
const result = await run(options);
|
|
96
96
|
const failed = Object.entries(result.results).filter(([, lane]) => !lane.ok);
|
|
97
97
|
last = { at: startedAt, ok: result.ok, lanes: Object.keys(result.results), failed: failed.map(([lane]) => lane) };
|
|
98
98
|
report(result.results);
|
|
@@ -135,6 +135,17 @@ export function createSeazonaScheduler({
|
|
|
135
135
|
nextRunAt = null;
|
|
136
136
|
await running;
|
|
137
137
|
},
|
|
138
|
+
// Sync now (server/core/sync-now.js): a person asks for a run now, with
|
|
139
|
+
// `options` (the lanes to read). While a run is going it starts nothing
|
|
140
|
+
// and answers 'running'. Otherwise the waiting run moves to now; the
|
|
141
|
+
// schedule goes on from its end as usual.
|
|
142
|
+
runNow(options = {}) {
|
|
143
|
+
if (stopped) return 'stopped';
|
|
144
|
+
if (running) return 'running';
|
|
145
|
+
if (timer) timers.clearTimeout(timer);
|
|
146
|
+
tick(options);
|
|
147
|
+
return 'started';
|
|
148
|
+
},
|
|
138
149
|
status() {
|
|
139
150
|
return { running: Boolean(running), nextRunAt, lastRun: last, stuckSince };
|
|
140
151
|
},
|
|
@@ -206,6 +206,9 @@ export function resolveSeazonaSettings(env = process.env) {
|
|
|
206
206
|
// order number, for "Open in Seazona" on a stage move. Optional; one
|
|
207
207
|
// without {ref} is ignored.
|
|
208
208
|
orderUrlTemplate: orderUrlTemplate(env.SEAZONA_ORDER_URL),
|
|
209
|
+
// "Include in Sync now" (server/core/sync-now.js): on unless the lab
|
|
210
|
+
// turns it off.
|
|
211
|
+
syncNow: !['0', 'false', 'off', 'no'].includes(text(env.SEAZONA_SYNC_NOW).toLowerCase()),
|
|
209
212
|
};
|
|
210
213
|
|
|
211
214
|
if (mode === 'live' || mode === 'sandbox') {
|
|
@@ -130,8 +130,15 @@ function laneReadsFrom(state, sync) {
|
|
|
130
130
|
return Number.isFinite(cursorMs) ? cursorMs - sync.cursorOverlapMs : null;
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
-
|
|
133
|
+
// What an orders cycle reads from each list row. 2: the remake flag
|
|
134
|
+
// (Seazona's isRemake, migration 0008). A cycle that finished before Flow
|
|
135
|
+
// kept the flag does not count, so the next run starts a new one and old
|
|
136
|
+
// orders get their flag without waiting out SEAZONA_SWEEP_CYCLE_DAYS.
|
|
137
|
+
export const ORDER_CYCLE_READS = 2;
|
|
138
|
+
|
|
139
|
+
function isDue(progress, at, sync, kind) {
|
|
134
140
|
if (progress?.cycleStartedAt) return true;
|
|
141
|
+
if (kind === 'orders' && (progress?.reads ?? 1) < ORDER_CYCLE_READS) return true;
|
|
135
142
|
const finished = Date.parse(progress?.lastFinishedAt ?? '');
|
|
136
143
|
return !Number.isFinite(finished) || at.getTime() - finished >= sync.sweepCycleMs;
|
|
137
144
|
}
|
|
@@ -253,7 +260,7 @@ async function sweepOrders(ctx, cursor, helpers, found) {
|
|
|
253
260
|
progress.days.shift();
|
|
254
261
|
progress.checkedDays += 1;
|
|
255
262
|
}
|
|
256
|
-
cursor.orders = { lastFinishedAt: at.toISOString(), checkedDays: progress.checkedDays };
|
|
263
|
+
cursor.orders = { lastFinishedAt: at.toISOString(), checkedDays: progress.checkedDays, reads: ORDER_CYCLE_READS };
|
|
257
264
|
}
|
|
258
265
|
|
|
259
266
|
// Clients and invoices: the whole list, then the held records it left out.
|
|
@@ -501,7 +508,7 @@ export async function runSweep(ctx, helpers) {
|
|
|
501
508
|
try {
|
|
502
509
|
for (const kind of SWEEP_KINDS) {
|
|
503
510
|
if (budget.left <= 0) break;
|
|
504
|
-
if (!isDue(cursor[kind], at, sync)) continue;
|
|
511
|
+
if (!isDue(cursor[kind], at, sync, kind)) continue;
|
|
505
512
|
if (kind === 'orders') await sweepOrders(sweepCtx, cursor, helpers, found);
|
|
506
513
|
else await sweepWholeList(sweepCtx, kind, cursor, laneStates, helpers, found);
|
|
507
514
|
}
|
|
@@ -2,6 +2,8 @@ import { eq, sql } from 'drizzle-orm';
|
|
|
2
2
|
import express from 'express';
|
|
3
3
|
import { FlowError } from '../core/errors.js';
|
|
4
4
|
import { deriveKey, openSecret, sealSecret } from '../core/secrets.js';
|
|
5
|
+
import { startSyncNow, syncNowStatus } from '../core/sync-now.js';
|
|
6
|
+
import { createActionGuard } from './call-guard.js';
|
|
5
7
|
import { integrationSettingEvents, integrationSettings, organisations } from '../db/schema.js';
|
|
6
8
|
|
|
7
9
|
// Integration setup in Settings (docs/INTEGRATIONS.md, "Set up in Flow"). An
|
|
@@ -183,8 +185,11 @@ export async function saveSettings(db, { adapter, secrets, actor, enabled, value
|
|
|
183
185
|
|
|
184
186
|
// GET /api/integrations (owner): every adapter with settings, its state and
|
|
185
187
|
// what is set. PUT /api/integrations/:id (owner): save and apply.
|
|
186
|
-
export function createSetupRouter({ db, registry, personAuth, secrets, baseEnv = process.env, log = console }) {
|
|
188
|
+
export function createSetupRouter({ db, registry, personAuth, secrets, baseEnv = process.env, log = console, buttonCooldowns = true }) {
|
|
187
189
|
const router = express.Router();
|
|
190
|
+
// Sync now can call Xero at a press, so it is an action (docs/INTEGRATIONS.md,
|
|
191
|
+
// "Button presses"): one a minute, for all and for each integration alone.
|
|
192
|
+
const actions = createActionGuard({ integration: 'sync-now', actions: { syncNow: { cooldownMs: 60_000 } }, off: !buttonCooldowns });
|
|
188
193
|
const owner = [personAuth.requireSession, personAuth.requireRole('owner')];
|
|
189
194
|
// A change needs a verified owner sign-in, as connecting Xero does.
|
|
190
195
|
const verifiedOwner = [personAuth.requireSession, personAuth.requireOwner];
|
|
@@ -205,6 +210,21 @@ export function createSetupRouter({ db, registry, personAuth, secrets, baseEnv =
|
|
|
205
210
|
} catch (error) { next(error); }
|
|
206
211
|
});
|
|
207
212
|
|
|
213
|
+
// Sync now (server/core/sync-now.js): what the button shows, and the
|
|
214
|
+
// press. Reads only; the reads stay inside each call guard.
|
|
215
|
+
router.get('/sync-now', ...owner, async (_req, res, next) => {
|
|
216
|
+
try {
|
|
217
|
+
res.json({ ok: true, integrations: await syncNowStatus(registry) });
|
|
218
|
+
} catch (error) { next(error); }
|
|
219
|
+
});
|
|
220
|
+
router.post('/sync-now', ...owner, express.json({ limit: '4kb' }), actions.route('syncNow', { key: (req) => req.body?.only ?? 'all' }), async (req, res, next) => {
|
|
221
|
+
try {
|
|
222
|
+
const only = req.body?.only ?? null;
|
|
223
|
+
if (only !== null && !registry.get(only)) return res.status(404).json({ ok: false, code: 'not_found', error: 'No such integration' });
|
|
224
|
+
res.json({ ok: true, results: await startSyncNow(registry, { only, actor: req.person.id, log }) });
|
|
225
|
+
} catch (error) { next(error); }
|
|
226
|
+
});
|
|
227
|
+
|
|
208
228
|
router.put('/:id', ...verifiedOwner, express.json({ limit: '32kb' }), async (req, res) => {
|
|
209
229
|
const adapter = registry.get(req.params.id);
|
|
210
230
|
if (!adapter?.setup) return res.status(404).json({ ok: false, code: 'not_found', error: 'No such integration' });
|
|
@@ -48,6 +48,7 @@ export const XERO_SETUP = {
|
|
|
48
48
|
{ name: 'XERO_TENANT_NAME', label: 'Xero organisation name', help: 'Live mode connects only the organisation with this exact name.' },
|
|
49
49
|
{ name: 'XERO_ALLOW_LIVE_WRITES', label: 'Create draft invoices in live Xero', kind: 'onoff', help: 'Off: Flow only reads from Xero.' },
|
|
50
50
|
{ name: 'XERO_CREATE_CONTACTS', label: 'Create missing clients', kind: 'onoff', default: 'on', help: 'On: a pending invoice whose client is not in Xero offers Create. Off: add the client in Xero, then sync.' },
|
|
51
|
+
{ name: 'XERO_SYNC_NOW', label: 'Include in Sync now', kind: 'onoff', default: 'on', help: 'On: Sync now on the Integrations page also reads Xero\'s changed invoices and credit notes. Each uses a few of the day\'s Xero calls.' },
|
|
51
52
|
{ name: 'XERO_WEBHOOK_KEY', label: 'Webhook key', secret: true, help: 'From the Xero app\'s Webhooks page. Empty: Flow reads Xero\'s changes every 15 minutes instead.' },
|
|
52
53
|
],
|
|
53
54
|
derivedKeys: ['XERO_TOKEN_KEY'],
|
|
@@ -160,6 +161,30 @@ export function createXeroAdapter({ runtime: given = null } = {}) {
|
|
|
160
161
|
// Xero's latest changes into the copy just before Flow drafts by rule or
|
|
161
162
|
// sends (webhooks.js); no call when the copy is current.
|
|
162
163
|
readChanges: ({ organisationId }) => readChangesFirst(database, current(), organisationId),
|
|
164
|
+
// Sync now (server/core/sync-now.js): Xero's latest invoice and credit
|
|
165
|
+
// note changes into the copy now, for each connected organisation, even
|
|
166
|
+
// when the copy counts as current. Off while "Include in Sync now" is off,
|
|
167
|
+
// unless a person asked for Xero alone (`asked`, its own Sync button).
|
|
168
|
+
syncNow: async ({ asked = false } = {}) => {
|
|
169
|
+
const { settings } = current();
|
|
170
|
+
if (!settings.syncNow && !asked) return { state: 'off' };
|
|
171
|
+
const { rows } = await database.execute(sql`select organisation_id from xero_connections where mode = ${settings.mode}`);
|
|
172
|
+
if (!rows.length) return { state: 'unavailable', reason: 'Xero is not connected' };
|
|
173
|
+
let changed = 0;
|
|
174
|
+
for (const row of rows) {
|
|
175
|
+
const answer = await readChangesFirst(database, current(), row.organisation_id, { force: true });
|
|
176
|
+
if (answer.state === 'unavailable') return answer;
|
|
177
|
+
changed += answer.changed ?? 0;
|
|
178
|
+
}
|
|
179
|
+
return { state: 'read', changed };
|
|
180
|
+
},
|
|
181
|
+
syncNowStatus: async () => {
|
|
182
|
+
const { settings } = current();
|
|
183
|
+
const { rows: [row] } = await database.execute(sql`select max(c.last_read_at) as at from xero_mirror_cursors c
|
|
184
|
+
join xero_connections x on x.organisation_id = c.organisation_id and x.mode = ${settings.mode}
|
|
185
|
+
where c.resource = 'invoices'`);
|
|
186
|
+
return { included: settings.syncNow, running: false, lastAt: row?.at ? new Date(row.at).toISOString() : null, ok: null };
|
|
187
|
+
},
|
|
163
188
|
// Active Xero contacts, from Flow's copy (lookup.js).
|
|
164
189
|
listCustomers: ({ organisationId }) => listCustomers(database, organisationId),
|
|
165
190
|
// Create the Xero contact for one Flow contact, when that setting is on
|
|
@@ -196,6 +196,9 @@ export function resolveXeroSettings(env = process.env) {
|
|
|
196
196
|
// On unless the lab turns it off: a pending invoice whose client is not
|
|
197
197
|
// in Xero offers to create that client (docs/CONTACTS.md).
|
|
198
198
|
createContacts: readOnOff(env, 'XERO_CREATE_CONTACTS', true),
|
|
199
|
+
// On unless the lab turns it off: Sync now (server/core/sync-now.js) also
|
|
200
|
+
// reads Xero's changes. Each Sync now costs a few of the day's calls.
|
|
201
|
+
syncNow: readOnOff(env, 'XERO_SYNC_NOW', true),
|
|
199
202
|
mirrorPageSize: Math.min(readPositiveInteger(env, 'XERO_MIRROR_PAGE_SIZE', DEFAULT_MIRROR_PAGE_SIZE), MAX_MIRROR_PAGE_SIZE),
|
|
200
203
|
// The share of the day's calls the mirror leaves for drafts and people.
|
|
201
204
|
mirrorReserveFraction: readFraction(env, 'XERO_MIRROR_RESERVE_FRACTION', 0.3),
|
|
@@ -272,16 +272,17 @@ export async function dueDailyChecks(db, runtime, { now = Date.now() } = {}) {
|
|
|
272
272
|
// unavailable the read could not run (`reason`); act on nothing
|
|
273
273
|
export const READ_FIRST_MS = 10 * 60 * 1000;
|
|
274
274
|
|
|
275
|
-
|
|
275
|
+
// `force` (Sync now) reads even when the copy counts as current.
|
|
276
|
+
export async function readChangesFirst(db, runtime, organisationId, { now = Date.now, force = false } = {}) {
|
|
276
277
|
const { rows: [connection] } = await db.execute(sql`select tenant_id from xero_connections
|
|
277
278
|
where organisation_id = ${organisationId} and mode = ${runtime.settings.mode}`);
|
|
278
279
|
if (!connection) return { state: 'unavailable', reason: 'Xero is not connected' };
|
|
279
280
|
const { rows: [{ waiting }] } = await db.execute(sql`select count(*)::int as waiting from xero_webhook_events
|
|
280
281
|
where organisation_id = ${organisationId} and pending`);
|
|
281
|
-
if (!waiting && await webhooksActive(db, runtime, organisationId, { now: Number(now()) })) return { state: 'fresh' };
|
|
282
|
+
if (!force && !waiting && await webhooksActive(db, runtime, organisationId, { now: Number(now()) })) return { state: 'fresh' };
|
|
282
283
|
const { rows: [{ oldest }] } = await db.execute(sql`select min(last_read_at) as oldest from xero_mirror_cursors
|
|
283
284
|
where organisation_id = ${organisationId} and resource in ('invoices', 'credit_notes')`);
|
|
284
|
-
if (!waiting && oldest && Number(now()) - new Date(oldest).getTime() <= READ_FIRST_MS) return { state: 'fresh' };
|
|
285
|
+
if (!force && !waiting && oldest && Number(now()) - new Date(oldest).getTime() <= READ_FIRST_MS) return { state: 'fresh' };
|
|
285
286
|
try {
|
|
286
287
|
const inbox = await readInbox(db, runtime, organisationId, { now });
|
|
287
288
|
if (inbox.stopped) return { state: 'unavailable', reason: inbox.stopped === 'budget' ? 'the day\'s Xero calls are kept for people' : 'Xero calls are stopped' };
|
package/server/ui/app.js
CHANGED
|
@@ -640,6 +640,50 @@ function integrationLogo(integration, size = '') {
|
|
|
640
640
|
file ? h('img', { src: `/assets/ui/logos/${file}`, alt: '' }) : icon('plug'));
|
|
641
641
|
}
|
|
642
642
|
|
|
643
|
+
// Sync now (server/core/sync-now.js): reads each integration whose "Include
|
|
644
|
+
// in Sync now" setting is on, now. With `only`, it is that integration's own
|
|
645
|
+
// Sync on its page, and reads it whatever the setting says. Which ones, and
|
|
646
|
+
// when the last read started, are on hover. While a read goes on, the button
|
|
647
|
+
// says so and asks Flow again every 5 seconds (Flow's data only). Hidden while
|
|
648
|
+
// nothing it would read can sync.
|
|
649
|
+
function syncNowButton({ only = null } = {}) {
|
|
650
|
+
const word = only ? 'Sync' : 'Sync now';
|
|
651
|
+
const button = h('button', { class: 'fx-btn', type: 'button', hidden: true, disabled: true }, word);
|
|
652
|
+
const when = (at) => new Date(at).toLocaleString('en-AU', { dateStyle: 'medium', timeStyle: 'short' });
|
|
653
|
+
let timer = null;
|
|
654
|
+
const show = (all) => {
|
|
655
|
+
const list = only ? all.filter((i) => i.id === only) : all;
|
|
656
|
+
const included = only ? list : list.filter((i) => i.included);
|
|
657
|
+
const running = included.some((i) => i.running);
|
|
658
|
+
const last = included.map((i) => i.lastAt).filter(Boolean).sort().pop();
|
|
659
|
+
button.hidden = !list.length;
|
|
660
|
+
button.disabled = running || !included.length;
|
|
661
|
+
button.replaceChildren(...(running ? [h('span', { class: 'fx-spinner fx-spinner--sm', 'aria-hidden': 'true' }), 'Syncing'] : [word]));
|
|
662
|
+
button.title = included.length
|
|
663
|
+
? `Reads ${included.map((i) => i.name).join(' and ')}.${last ? ` Last read ${when(last)}.` : ''}`
|
|
664
|
+
: 'Turn on "Include in Sync now" in an integration\'s settings.';
|
|
665
|
+
clearTimeout(timer);
|
|
666
|
+
if (running) timer = setTimeout(() => { if (button.isConnected) load(); }, 5000);
|
|
667
|
+
};
|
|
668
|
+
const load = () => flow('/api/integrations/sync-now')
|
|
669
|
+
.then(({ integrations }) => show(integrations))
|
|
670
|
+
.catch(() => { button.hidden = true; });
|
|
671
|
+
button.addEventListener('click', async () => {
|
|
672
|
+
button.disabled = true;
|
|
673
|
+
try {
|
|
674
|
+
const { results } = await flow('/api/integrations/sync-now', { method: 'POST', body: JSON.stringify(only ? { only } : {}) });
|
|
675
|
+
for (const r of results.filter((r) => r.state === 'unavailable')) toast(`${r.name}: ${r.reason}`, 'danger');
|
|
676
|
+
const reading = results.filter((r) => ['started', 'running', 'read'].includes(r.state));
|
|
677
|
+
if (reading.length) toast(`Reading ${reading.map((r) => r.name).join(' and ')}.`, 'success');
|
|
678
|
+
} catch (error) {
|
|
679
|
+
toast(error.message, 'danger');
|
|
680
|
+
}
|
|
681
|
+
load();
|
|
682
|
+
});
|
|
683
|
+
load();
|
|
684
|
+
return button;
|
|
685
|
+
}
|
|
686
|
+
|
|
643
687
|
async function renderIntegrations(main) {
|
|
644
688
|
const state = { tab: 'all', category: 'all', q: '' };
|
|
645
689
|
const tabs = h('div', { class: 'fx-seg', role: 'tablist' });
|
|
@@ -649,7 +693,7 @@ async function renderIntegrations(main) {
|
|
|
649
693
|
const notices = h('div', { class: 'fx-fields' });
|
|
650
694
|
const content = h('div', { class: 'fx-int-sections' }, h('div', { class: 'fx-skeleton' }));
|
|
651
695
|
main.append(
|
|
652
|
-
pageHeader('Integrations'),
|
|
696
|
+
pageHeader('Integrations', { actions: [syncNowButton()] }),
|
|
653
697
|
alertStrip(ui(), 'integrations').element,
|
|
654
698
|
h('div', { class: 'fx-int-bar' }, tabs, h('div', { class: 'fx-row' }, category, search)),
|
|
655
699
|
notices, content);
|
|
@@ -848,6 +892,18 @@ async function renderIntegration(main, id) {
|
|
|
848
892
|
}
|
|
849
893
|
};
|
|
850
894
|
|
|
895
|
+
// A disconnect stops the integration, so it asks first: one stray click
|
|
896
|
+
// must not stop a lab's sync.
|
|
897
|
+
const disconnect = async () => {
|
|
898
|
+
const ok = await dialog({
|
|
899
|
+
title: `Disconnect ${current.name}?`,
|
|
900
|
+
body: h('p', {}, `Flow stops working with ${current.name} until someone connects it again. Its settings and Flow's records stay.`),
|
|
901
|
+
confirm: 'Disconnect',
|
|
902
|
+
tone: 'danger',
|
|
903
|
+
});
|
|
904
|
+
if (ok) await save(false);
|
|
905
|
+
};
|
|
906
|
+
|
|
851
907
|
const on = current.enabled;
|
|
852
908
|
const guide = current.guide;
|
|
853
909
|
const hostOwned = current.enabledBy === 'host';
|
|
@@ -855,10 +911,13 @@ async function renderIntegration(main, id) {
|
|
|
855
911
|
const modeLabel = mode?.choices?.find((c) => c.value === mode.value)?.label ?? mode?.value;
|
|
856
912
|
const busy = progress && progress.steps.some((step) => step.state === 'running');
|
|
857
913
|
|
|
858
|
-
const primary = !canChange || hostOwned ? null
|
|
859
|
-
? h('button', { class: 'fx-btn', type: 'button', onclick: () => save(false) }, 'Disconnect')
|
|
914
|
+
const primary = !canChange || hostOwned || on ? null
|
|
860
915
|
: h('button', { class: 'fx-btn fx-btn--primary', type: 'button', disabled: busy, onclick: connect },
|
|
861
916
|
busy ? h('span', { class: 'fx-spinner fx-spinner--sm', 'aria-hidden': 'true' }) : null, busy ? 'Connecting' : 'Connect');
|
|
917
|
+
// Disconnect sits at the end of the page, away from Sync.
|
|
918
|
+
const disconnectRow = !canChange || hostOwned || !on ? null
|
|
919
|
+
: h('div', { class: 'fx-int-section' }, h('div', { class: 'fx-row fx-row--end' },
|
|
920
|
+
h('button', { class: 'fx-btn fx-btn--ghost fx-btn--danger', type: 'button', onclick: disconnect }, 'Disconnect')));
|
|
862
921
|
|
|
863
922
|
host.replaceChildren(
|
|
864
923
|
crumbs(['/integrations', 'Integrations'], current.name),
|
|
@@ -869,6 +928,7 @@ async function renderIntegration(main, id) {
|
|
|
869
928
|
h('h1', { class: 'fx-title' }, current.name),
|
|
870
929
|
h('div', { class: 'fx-int-hero__actions' },
|
|
871
930
|
on ? statusPill(current) : null,
|
|
931
|
+
on && current.state === 'ready' ? syncNowButton({ only: current.id }) : null,
|
|
872
932
|
primary,
|
|
873
933
|
progress ? connectProgress(progress) : null)),
|
|
874
934
|
alerts.element,
|
|
@@ -893,6 +953,7 @@ async function renderIntegration(main, id) {
|
|
|
893
953
|
h('summary', {}, 'Advanced'),
|
|
894
954
|
h('div', { class: 'fx-settings' }, advanced)) : null,
|
|
895
955
|
canChange && on && inputs.size ? h('div', { class: 'fx-row fx-row--end' }, h('button', { class: 'fx-btn', type: 'button', onclick: () => save(undefined) }, 'Save changes')) : null) : null,
|
|
956
|
+
disconnectRow,
|
|
896
957
|
].filter(Boolean)),
|
|
897
958
|
h('aside', { class: 'fx-int-page__meta' },
|
|
898
959
|
h('dl', { class: 'fx-meta' }, ...[
|
package/server/ui/flow.css
CHANGED
|
@@ -1888,7 +1888,8 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
|
|
|
1888
1888
|
content: ''; position: absolute; top: 3px; left: 3px; width: 16px; height: 16px;
|
|
1889
1889
|
border-radius: 50%; background: var(--t-paper); transition: transform 0.15s;
|
|
1890
1890
|
}
|
|
1891
|
-
|
|
1891
|
+
/* background-color only: the shorthand would reset background-clip and fill the touch border. */
|
|
1892
|
+
.fx-switch[aria-checked="true"] { background-color: var(--t-primary); }
|
|
1892
1893
|
.fx-switch[aria-checked="true"]::after { transform: translateX(18px); }
|
|
1893
1894
|
.fx-switch:focus-visible { outline: none; box-shadow: var(--fx-focus); }
|
|
1894
1895
|
.fx-switch:disabled { cursor: default; opacity: 0.6; }
|
package/server/ui/kit.js
CHANGED
|
@@ -244,7 +244,7 @@ export function dialog({ title, body, confirm, tone = 'primary', choices = null
|
|
|
244
244
|
h('h2', { class: 'fx-dialog__title' }, title),
|
|
245
245
|
choiceList ?? body,
|
|
246
246
|
actions);
|
|
247
|
-
const element = applyPhoneRule(h('dialog', { class: 'fx-dialog' }, form), 'dialog');
|
|
247
|
+
const element = applyPhoneRule(h('dialog', { class: 'fx-dialog', 'aria-label': title }, form), 'dialog');
|
|
248
248
|
element.addEventListener('close', () => {
|
|
249
249
|
const value = element.returnValue;
|
|
250
250
|
element.remove();
|
|
@@ -258,7 +258,8 @@ export function dialog({ title, body, confirm, tone = 'primary', choices = null
|
|
|
258
258
|
if (choices) element.addEventListener('click', (event) => { if (event.target === element) element.close(); });
|
|
259
259
|
document.body.append(element);
|
|
260
260
|
element.showModal();
|
|
261
|
-
|
|
261
|
+
// A danger step starts on Cancel, so Enter or a second tap does not confirm it.
|
|
262
|
+
element.querySelector(choices ? '.fx-sheet__choice' : `input, select, button[value="${tone === 'danger' ? 'cancel' : 'ok'}"]`)?.focus();
|
|
262
263
|
});
|
|
263
264
|
}
|
|
264
265
|
|