@arcforgelabs/flow 2026.10.32 → 2026.10.34
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 +9 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
- package/server/api/custom-fields.js +114 -0
- package/server/api/invoicing.js +58 -25
- package/server/api/work-moves.js +80 -0
- package/server/app.js +16 -0
- package/server/core/custom-fields.js +300 -0
- package/server/core/invoicing-rules.js +50 -4
- package/server/core/job-board.js +17 -4
- package/server/core/ledger.js +242 -6
- package/server/core/pipeline.js +29 -21
- package/server/core/read-before-act.js +96 -0
- package/server/core/reconcile.js +350 -0
- package/server/core/sending.js +44 -6
- package/server/core/tracing.js +64 -0
- package/server/db/migrations/0017_sync_basis.sql +1 -0
- package/server/db/migrations/0018_custom_fields.sql +78 -0
- package/server/db/migrations/meta/0017_snapshot.json +3867 -0
- package/server/db/migrations/meta/0018_snapshot.json +4289 -0
- package/server/db/migrations/meta/_journal.json +14 -0
- package/server/db/schema.js +62 -0
- package/server/index.js +11 -1
- package/server/integrations/port.js +10 -1
- package/server/integrations/seazona/act.js +48 -0
- package/server/integrations/seazona/feed.js +70 -50
- package/server/integrations/seazona/governor.js +28 -2
- package/server/integrations/seazona/index.js +107 -4
- package/server/integrations/seazona/lanes.js +81 -8
- package/server/integrations/seazona/ledger.js +14 -9
- package/server/integrations/seazona/migrations/0005_record_reread.sql +1 -0
- package/server/integrations/seazona/migrations/0006_sweep_findings.sql +11 -0
- package/server/integrations/seazona/migrations/0007_stage_moves.sql +18 -0
- package/server/integrations/seazona/migrations/meta/0005_snapshot.json +1329 -0
- package/server/integrations/seazona/migrations/meta/0006_snapshot.json +1403 -0
- package/server/integrations/seazona/migrations/meta/0007_snapshot.json +1519 -0
- package/server/integrations/seazona/migrations/meta/_journal.json +21 -0
- package/server/integrations/seazona/reconcile.js +40 -0
- package/server/integrations/seazona/scheduler.js +32 -4
- package/server/integrations/seazona/schema.js +47 -0
- package/server/integrations/seazona/settings.js +26 -0
- package/server/integrations/seazona/stage-moves.js +135 -0
- package/server/integrations/seazona/store.js +24 -2
- package/server/integrations/seazona/sweep.js +574 -0
- package/server/integrations/seazona/tables.js +3 -0
- package/server/integrations/sentry/index.js +61 -10
- package/server/integrations/xero/index.js +4 -0
- package/server/integrations/xero/webhooks.js +35 -0
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +40 -2
- package/server/ui/custom-fields.js +190 -0
- package/server/ui/flow.css +57 -9
- package/server/ui/invoicing.js +184 -22
- package/server/ui/job-board.js +2 -1
- package/server/ui/jobs.js +22 -2
- package/server/ui/monitoring.js +44 -2
- package/server/ui/routes.js +1 -1
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from './map.js';
|
|
12
12
|
import { runHistoryBackfill } from './history/backfill.js';
|
|
13
13
|
import { seazonaOrders } from './schema.js';
|
|
14
|
+
import { runSweep } from './sweep.js';
|
|
14
15
|
import {
|
|
15
16
|
claimLane,
|
|
16
17
|
clearOrderFailure,
|
|
@@ -22,6 +23,8 @@ import {
|
|
|
22
23
|
readQuarantinedOrders,
|
|
23
24
|
readQuarantinedRecordCounts,
|
|
24
25
|
readQuarantinedRecords,
|
|
26
|
+
readRecordRereadCounts,
|
|
27
|
+
readRecordsDueForReread,
|
|
25
28
|
recordOrderFailure,
|
|
26
29
|
recordRecordFailure,
|
|
27
30
|
selectActiveOrders,
|
|
@@ -41,6 +44,8 @@ import {
|
|
|
41
44
|
// users the lab user list, daily
|
|
42
45
|
// history past orders, a few calls per run, only while a person
|
|
43
46
|
// has started a backfill (history/backfill.js)
|
|
47
|
+
// sweep whole lists again, slowly, for what the others missed
|
|
48
|
+
// (issue #126, sweep.js)
|
|
44
49
|
//
|
|
45
50
|
// Issue #12 holds for every order read: a 404 marks the order deleted in
|
|
46
51
|
// Seazona, an empty answer is a failure, each failure is recorded per order
|
|
@@ -58,7 +63,7 @@ import {
|
|
|
58
63
|
// only after a person saves the settings or Flow restarts on a new release.
|
|
59
64
|
// Calls the governor stopped (SeazonaCallsStopped) wait out its pause.
|
|
60
65
|
|
|
61
|
-
export const LANES = Object.freeze(['invoices', 'orders_recent', 'orders_active', 'clients', 'products', 'users', 'history']);
|
|
66
|
+
export const LANES = Object.freeze(['invoices', 'orders_recent', 'orders_active', 'clients', 'products', 'users', 'history', 'sweep']);
|
|
62
67
|
|
|
63
68
|
const DAY_MS = 24 * 60 * 60_000;
|
|
64
69
|
const MAX_REPORTED = 25;
|
|
@@ -237,6 +242,7 @@ async function syncListWithDetail(ctx, { mirror, what, items, keyOf, idOf, signa
|
|
|
237
242
|
at,
|
|
238
243
|
quarantineFailures: sync.orderQuarantineFailures,
|
|
239
244
|
quarantineMs: sync.orderQuarantineMs,
|
|
245
|
+
detailId: id,
|
|
240
246
|
});
|
|
241
247
|
if (recorded.quarantinedUntil) newlyQuarantined += 1;
|
|
242
248
|
failures.push({ id: key, ...failure, ...recorded });
|
|
@@ -257,6 +263,52 @@ async function syncListWithDetail(ctx, { mirror, what, items, keyOf, idOf, signa
|
|
|
257
263
|
return { summary, detailReads, listCount: items.length, failureDetails, anyFailed, newlyQuarantined, quarantined: skipped.length };
|
|
258
264
|
}
|
|
259
265
|
|
|
266
|
+
// Issue #142: a quarantined invoice or client is in a later list only if it
|
|
267
|
+
// changes again, so when its wait ends the lane reads its detail by id. A
|
|
268
|
+
// success stores it and clears the failure; a 404 clears the failure (the
|
|
269
|
+
// record is gone, issue #126); a failure quarantines it again, with no new
|
|
270
|
+
// report. Each read goes through the call guard like any other.
|
|
271
|
+
async function rereadReleased(ctx, { mirror, what, detailPath, map }) {
|
|
272
|
+
const { db, client, sync, at } = ctx;
|
|
273
|
+
const due = sync.recordRereadsPerRun ? await readRecordsDueForReread(db, mirror, { at, limit: sync.recordRereadsPerRun }) : [];
|
|
274
|
+
const result = { summary: { created: 0, changed: 0, unchanged: 0 }, detailReads: 0, reread: 0, gone: 0, failed: 0 };
|
|
275
|
+
for (const row of due) {
|
|
276
|
+
result.detailReads += 1;
|
|
277
|
+
try {
|
|
278
|
+
const detail = await client.get(detailPath(row.detailId));
|
|
279
|
+
if (!detail || typeof detail !== 'object' || Array.isArray(detail)) throw emptyResponse(`${what} ${row.recordKey}`);
|
|
280
|
+
addSummary(result.summary, await upsertRecords(db, mirror, [map(detail)], { at }));
|
|
281
|
+
await clearRecordFailures(db, mirror, [row.recordKey]);
|
|
282
|
+
result.reread += 1;
|
|
283
|
+
} catch (error) {
|
|
284
|
+
if (isLaneLevel(error)) throw error;
|
|
285
|
+
if (error?.statusCode === 404) {
|
|
286
|
+
await clearRecordFailures(db, mirror, [row.recordKey]);
|
|
287
|
+
result.gone += 1;
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
await recordRecordFailure(db, mirror, row.recordKey, describeReadFailure(error), {
|
|
291
|
+
at,
|
|
292
|
+
quarantineFailures: sync.orderQuarantineFailures,
|
|
293
|
+
quarantineMs: sync.orderQuarantineMs,
|
|
294
|
+
});
|
|
295
|
+
result.failed += 1;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return result;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Adds the re-reads to a lane's result.
|
|
302
|
+
function withRereads(result, reread) {
|
|
303
|
+
if (!reread.detailReads) return result;
|
|
304
|
+
addSummary(result.summary, reread.summary);
|
|
305
|
+
return {
|
|
306
|
+
...result,
|
|
307
|
+
detailReads: result.detailReads + reread.detailReads,
|
|
308
|
+
rereads: { attempted: reread.detailReads, read: reread.reread, gone: reread.gone, failed: reread.failed },
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
260
312
|
// Where the next changed-list read starts. A run that stopped inside a
|
|
261
313
|
// window keeps the exact start of the next part (`cursor.since`); otherwise
|
|
262
314
|
// the last finished run's start, less the overlap.
|
|
@@ -351,7 +403,13 @@ const laneRuns = {
|
|
|
351
403
|
const { client, sync, at } = ctx;
|
|
352
404
|
const firstSince = cursorSince(ctx, new Date(floorUtcDay(at.getTime()) - sync.invoiceLookbackDays * DAY_MS).toISOString());
|
|
353
405
|
const mapOptions = { keepPatientReference: sync.keepPatientReference };
|
|
354
|
-
|
|
406
|
+
const reread = await rereadReleased(ctx, {
|
|
407
|
+
mirror: 'invoices',
|
|
408
|
+
what: 'invoice',
|
|
409
|
+
detailPath: (id) => `invoices/${encodeURIComponent(id)}`,
|
|
410
|
+
map: (detail) => mapInvoice({}, detail, mapOptions),
|
|
411
|
+
});
|
|
412
|
+
return withRereads(await walkChangedList(ctx, {
|
|
355
413
|
what: 'invoice',
|
|
356
414
|
firstSince,
|
|
357
415
|
// The guide: on or after lastModified (inclusive).
|
|
@@ -367,7 +425,7 @@ const laneRuns = {
|
|
|
367
425
|
detailPath: (id) => `invoices/${encodeURIComponent(id)}`,
|
|
368
426
|
map: (item, detail) => mapInvoice(item, detail, mapOptions),
|
|
369
427
|
}),
|
|
370
|
-
});
|
|
428
|
+
}), reread);
|
|
371
429
|
},
|
|
372
430
|
|
|
373
431
|
async orders_recent(ctx) {
|
|
@@ -485,10 +543,16 @@ const laneRuns = {
|
|
|
485
543
|
|
|
486
544
|
async clients(ctx) {
|
|
487
545
|
const { client } = ctx;
|
|
546
|
+
const reread = await rereadReleased(ctx, {
|
|
547
|
+
mirror: 'clients',
|
|
548
|
+
what: 'client',
|
|
549
|
+
detailPath: (id) => `clients/${encodeURIComponent(id)}`,
|
|
550
|
+
map: (detail) => mapClient({}, detail),
|
|
551
|
+
});
|
|
488
552
|
// The first run reads the whole list; later runs read changes only.
|
|
489
553
|
// Seazona answers the whole list only to an empty `?lastModified=`; a
|
|
490
554
|
// bare path is a 404.
|
|
491
|
-
return walkChangedList(ctx, {
|
|
555
|
+
return withRereads(await walkChangedList(ctx, {
|
|
492
556
|
what: 'client',
|
|
493
557
|
firstSince: cursorSince(ctx, ''),
|
|
494
558
|
// The guide says "modified after": step back a second so a row in the
|
|
@@ -505,10 +569,11 @@ const laneRuns = {
|
|
|
505
569
|
detailPath: (id) => `clients/${encodeURIComponent(id)}`,
|
|
506
570
|
map: (item, detail) => mapClient(item, detail),
|
|
507
571
|
}),
|
|
508
|
-
});
|
|
572
|
+
}), reread);
|
|
509
573
|
},
|
|
510
574
|
|
|
511
575
|
history: (ctx) => runHistoryBackfill(ctx, { isLaneLevel, describeReadFailure, emptyResponse, LaneError }),
|
|
576
|
+
sweep: (ctx) => runSweep(ctx, { readOrder, isLaneLevel, describeReadFailure, emptyResponse, LaneError }),
|
|
512
577
|
products: (ctx) => syncReference(ctx, 'products'),
|
|
513
578
|
users: (ctx) => syncReference(ctx, 'users'),
|
|
514
579
|
};
|
|
@@ -589,20 +654,27 @@ export async function runSeazonaSync(db, client, { settings, now = Date.now, onl
|
|
|
589
654
|
// What /health shows for the sync: each lane's state, the failing and
|
|
590
655
|
// quarantined orders by id and status, and how many orders are marked
|
|
591
656
|
// deleted in Seazona. No patient data.
|
|
657
|
+
//
|
|
658
|
+
// Issue #143: a lane that ran before and has not succeeded within its
|
|
659
|
+
// freshness limit (or two of its own cadences, if those are longer) is
|
|
660
|
+
// stale, and a stale lane makes `ok` false like a failed one.
|
|
592
661
|
export async function readSeazonaSyncHealth(db, settings, { now = Date.now } = {}) {
|
|
593
662
|
const nowMs = Number(now());
|
|
594
663
|
const states = await readLaneStates(db);
|
|
595
664
|
const quarantinedOrders = await readQuarantinedOrders(db, { at: new Date(nowMs), limit: MAX_REPORTED });
|
|
596
665
|
const [{ deleted }] = await db.select({ deleted: count() }).from(seazonaOrders).where(isNotNull(seazonaOrders.deletedInSourceAt));
|
|
597
666
|
const quarantinedRecords = await readQuarantinedRecordCounts(db, { at: new Date(nowMs) });
|
|
667
|
+
const rereadDue = await readRecordRereadCounts(db, { at: new Date(nowMs) });
|
|
598
668
|
const lanes = LANES.map((lane) => {
|
|
599
669
|
const state = states.get(lane);
|
|
600
|
-
const { freshnessMs } = settings.sync.lanes[lane];
|
|
670
|
+
const { freshnessMs, cadenceMs } = settings.sync.lanes[lane];
|
|
671
|
+
const staleAfterMs = Math.max(freshnessMs, 2 * cadenceMs);
|
|
601
672
|
const ageMs = state?.lastSuccessAt ? Math.max(0, nowMs - state.lastSuccessAt.getTime()) : null;
|
|
602
673
|
return {
|
|
603
674
|
lane,
|
|
604
675
|
status: state?.status ?? 'never_run',
|
|
605
|
-
fresh: ageMs !== null && ageMs <=
|
|
676
|
+
fresh: ageMs !== null && ageMs <= staleAfterMs,
|
|
677
|
+
stale: ageMs !== null && ageMs > staleAfterMs,
|
|
606
678
|
ageMs,
|
|
607
679
|
lastSuccessAt: state?.lastSuccessAt?.toISOString() ?? null,
|
|
608
680
|
lastAttemptAt: state?.lastAttemptAt?.toISOString() ?? null,
|
|
@@ -613,10 +685,11 @@ export async function readSeazonaSyncHealth(db, settings, { now = Date.now } = {
|
|
|
613
685
|
failureDetails: state?.failureDetails ?? null,
|
|
614
686
|
...(lane === 'orders_active' ? { quarantinedOrders } : {}),
|
|
615
687
|
...(quarantinedRecords[lane] ? { quarantinedRecords: quarantinedRecords[lane] } : {}),
|
|
688
|
+
...(rereadDue[lane] ? { rereadDue: rereadDue[lane] } : {}),
|
|
616
689
|
};
|
|
617
690
|
});
|
|
618
691
|
return {
|
|
619
|
-
ok: lanes.every((lane) => !['failed', 'held', 'stopped'].includes(lane.status)),
|
|
692
|
+
ok: lanes.every((lane) => !lane.stale && !['failed', 'held', 'stopped'].includes(lane.status)),
|
|
620
693
|
lanes,
|
|
621
694
|
deletedOrders: Number(deleted),
|
|
622
695
|
};
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
// issue #3). The adapter does not import the ledger: the core reads the
|
|
3
3
|
// billing feed and passes these shapes to it.
|
|
4
4
|
//
|
|
5
|
-
// item.ledgerJob
|
|
6
|
-
// withdrawn.
|
|
5
|
+
// item.ledgerJob -> observeJob(db, organisationId, { ...item.ledgerJob, contactId })
|
|
6
|
+
// withdrawn.ledgerWithdrawn -> observeWithdrawn(db, organisationId, withdrawn.ledgerWithdrawn)
|
|
7
7
|
//
|
|
8
8
|
// One Seazona order is one billable job. The contact is Flow's, so the core
|
|
9
9
|
// resolves it (from item.client) and adds contactId; the ledger keeps a job
|
|
@@ -56,15 +56,20 @@ export function toLedgerJob(item) {
|
|
|
56
56
|
};
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
|
|
63
|
-
|
|
59
|
+
// observeWithdrawn's input for an order that is no longer billable. An
|
|
60
|
+
// order deleted in Seazona sends its obligation, if there is one, to review:
|
|
61
|
+
// a person decides what happens to any invoice already made. An order whose
|
|
62
|
+
// status moved back (for example from Shipped to In Production) loses an
|
|
63
|
+
// unsent ready mark, and goes to review when its invoice was made.
|
|
64
|
+
export function toLedgerWithdrawn(entry) {
|
|
65
|
+
const deleted = entry.reason === 'deleted_in_seazona';
|
|
64
66
|
return {
|
|
65
67
|
sourceSystem: SEAZONA_SOURCE_SYSTEM,
|
|
66
68
|
sourceJobRef: entry.sourceId,
|
|
67
|
-
|
|
68
|
-
|
|
69
|
+
sourceStatus: entry.status ?? null,
|
|
70
|
+
deleted,
|
|
71
|
+
reason: deleted ? 'the order was deleted in Seazona' : null,
|
|
72
|
+
observedAt: entry.syncedAt ?? null,
|
|
73
|
+
actor: `system:${SEAZONA_SOURCE_SYSTEM}`,
|
|
69
74
|
};
|
|
70
75
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ALTER TABLE "seazona_record_read_failures" ADD COLUMN IF NOT EXISTS "detail_id" text;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS "seazona_sweep_findings" (
|
|
2
|
+
"resource" text NOT NULL,
|
|
3
|
+
"record_key" text NOT NULL,
|
|
4
|
+
"kind" text NOT NULL,
|
|
5
|
+
"first_found_at" timestamp with time zone NOT NULL,
|
|
6
|
+
"last_found_at" timestamp with time zone NOT NULL,
|
|
7
|
+
"detail" jsonb,
|
|
8
|
+
CONSTRAINT "seazona_sweep_findings_resource_record_key_kind_pk" PRIMARY KEY("resource","record_key","kind")
|
|
9
|
+
);
|
|
10
|
+
--> statement-breakpoint
|
|
11
|
+
CREATE INDEX IF NOT EXISTS "seazona_sweep_findings_found" ON "seazona_sweep_findings" USING btree ("last_found_at");
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS "seazona_person_settings" (
|
|
2
|
+
"person_id" text PRIMARY KEY NOT NULL,
|
|
3
|
+
"ask_before_stage_moves" boolean DEFAULT true NOT NULL,
|
|
4
|
+
"updated_at" timestamp with time zone NOT NULL
|
|
5
|
+
);
|
|
6
|
+
--> statement-breakpoint
|
|
7
|
+
CREATE TABLE IF NOT EXISTS "seazona_stage_moves" (
|
|
8
|
+
"order_id" text PRIMARY KEY NOT NULL,
|
|
9
|
+
"from_status" text,
|
|
10
|
+
"to_status" text NOT NULL,
|
|
11
|
+
"requested_by" text,
|
|
12
|
+
"requested_at" timestamp with time zone NOT NULL,
|
|
13
|
+
"expires_at" timestamp with time zone NOT NULL,
|
|
14
|
+
"state" text DEFAULT 'waiting' NOT NULL,
|
|
15
|
+
"settled_at" timestamp with time zone
|
|
16
|
+
);
|
|
17
|
+
--> statement-breakpoint
|
|
18
|
+
CREATE INDEX IF NOT EXISTS "seazona_stage_moves_state" ON "seazona_stage_moves" USING btree ("state","expires_at");
|