@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.
Files changed (57) hide show
  1. package/README.md +9 -1
  2. package/npm-shrinkwrap.json +2 -2
  3. package/package.json +2 -1
  4. package/server/api/custom-fields.js +114 -0
  5. package/server/api/invoicing.js +58 -25
  6. package/server/api/work-moves.js +80 -0
  7. package/server/app.js +16 -0
  8. package/server/core/custom-fields.js +300 -0
  9. package/server/core/invoicing-rules.js +50 -4
  10. package/server/core/job-board.js +17 -4
  11. package/server/core/ledger.js +242 -6
  12. package/server/core/pipeline.js +29 -21
  13. package/server/core/read-before-act.js +96 -0
  14. package/server/core/reconcile.js +350 -0
  15. package/server/core/sending.js +44 -6
  16. package/server/core/tracing.js +64 -0
  17. package/server/db/migrations/0017_sync_basis.sql +1 -0
  18. package/server/db/migrations/0018_custom_fields.sql +78 -0
  19. package/server/db/migrations/meta/0017_snapshot.json +3867 -0
  20. package/server/db/migrations/meta/0018_snapshot.json +4289 -0
  21. package/server/db/migrations/meta/_journal.json +14 -0
  22. package/server/db/schema.js +62 -0
  23. package/server/index.js +11 -1
  24. package/server/integrations/port.js +10 -1
  25. package/server/integrations/seazona/act.js +48 -0
  26. package/server/integrations/seazona/feed.js +70 -50
  27. package/server/integrations/seazona/governor.js +28 -2
  28. package/server/integrations/seazona/index.js +107 -4
  29. package/server/integrations/seazona/lanes.js +81 -8
  30. package/server/integrations/seazona/ledger.js +14 -9
  31. package/server/integrations/seazona/migrations/0005_record_reread.sql +1 -0
  32. package/server/integrations/seazona/migrations/0006_sweep_findings.sql +11 -0
  33. package/server/integrations/seazona/migrations/0007_stage_moves.sql +18 -0
  34. package/server/integrations/seazona/migrations/meta/0005_snapshot.json +1329 -0
  35. package/server/integrations/seazona/migrations/meta/0006_snapshot.json +1403 -0
  36. package/server/integrations/seazona/migrations/meta/0007_snapshot.json +1519 -0
  37. package/server/integrations/seazona/migrations/meta/_journal.json +21 -0
  38. package/server/integrations/seazona/reconcile.js +40 -0
  39. package/server/integrations/seazona/scheduler.js +32 -4
  40. package/server/integrations/seazona/schema.js +47 -0
  41. package/server/integrations/seazona/settings.js +26 -0
  42. package/server/integrations/seazona/stage-moves.js +135 -0
  43. package/server/integrations/seazona/store.js +24 -2
  44. package/server/integrations/seazona/sweep.js +574 -0
  45. package/server/integrations/seazona/tables.js +3 -0
  46. package/server/integrations/sentry/index.js +61 -10
  47. package/server/integrations/xero/index.js +4 -0
  48. package/server/integrations/xero/webhooks.js +35 -0
  49. package/server/ui/app-page.js +1 -1
  50. package/server/ui/app.js +40 -2
  51. package/server/ui/custom-fields.js +190 -0
  52. package/server/ui/flow.css +57 -9
  53. package/server/ui/invoicing.js +184 -22
  54. package/server/ui/job-board.js +2 -1
  55. package/server/ui/jobs.js +22 -2
  56. package/server/ui/monitoring.js +44 -2
  57. package/server/ui/routes.js +1 -1
@@ -120,6 +120,20 @@
120
120
  "when": 1792456270558,
121
121
  "tag": "0016_invoice_reference",
122
122
  "breakpoints": true
123
+ },
124
+ {
125
+ "idx": 17,
126
+ "version": "7",
127
+ "when": 1792458167234,
128
+ "tag": "0017_sync_basis",
129
+ "breakpoints": true
130
+ },
131
+ {
132
+ "idx": 18,
133
+ "version": "7",
134
+ "when": 1792472460808,
135
+ "tag": "0018_custom_fields",
136
+ "breakpoints": true
123
137
  }
124
138
  ]
125
139
  }
@@ -250,6 +250,9 @@ export const billingObligations = pgTable('billing_obligations', {
250
250
  holdReason: text('hold_reason'),
251
251
  // The Flow invoice made for this job.
252
252
  invoiceId: uuid('invoice_id'),
253
+ // The job facts the invoice was built from: the merge base for the
254
+ // three-way compare when the job changes later (server/core/ledger.js).
255
+ draftBasis: jsonb('draft_basis'),
253
256
  // What the accounting system reported, how it matched, and when Flow checked.
254
257
  accountingState: text('accounting_state').notNull().default('unknown'),
255
258
  accountingRef: text('accounting_ref'),
@@ -763,3 +766,62 @@ export const priceItemLinks = pgTable('price_item_links', {
763
766
  check('price_item_links_matched_by_valid', sql`${t.matchedBy} in ('code', 'person')`),
764
767
  check('price_item_links_system_valid', sql`${t.system} ~ '^[a-z][a-z0-9-]{1,31}$'`),
765
768
  ]);
769
+
770
+ // Custom fields (server/core/custom-fields.js, docs/CUSTOM_FIELDS.md): fields
771
+ // a lab makes for its jobs in Flow, in Settings, Custom fields. Only an owner
772
+ // or admin makes or changes one. A dropdown's options are
773
+ // [{ id, name, color }] in lane order; an option a person removes stays in
774
+ // `options` with `archived: true`, so old values and history still read.
775
+ // The type never changes after a field is made.
776
+ export const CUSTOM_FIELD_TYPES = ['dropdown', 'person', 'checkbox', 'date', 'number', 'text'];
777
+ export const jobFields = pgTable('job_fields', {
778
+ id: id(),
779
+ organisationId: organisationId().references(() => organisations.id),
780
+ name: text('name').notNull(),
781
+ type: text('type').notNull(),
782
+ options: jsonb('options').notNull().default(sql`'[]'::jsonb`),
783
+ position: integer('position').notNull(),
784
+ archivedAt: timestamp('archived_at', { withTimezone: true }),
785
+ createdBy: uuid('created_by').references(() => people.id, { onDelete: 'set null' }),
786
+ updatedBy: uuid('updated_by').references(() => people.id, { onDelete: 'set null' }),
787
+ createdAt: createdAt(),
788
+ updatedAt: updatedAt(),
789
+ }, (t) => [
790
+ unique('job_fields_org_id').on(t.organisationId, t.id),
791
+ check('job_fields_type_valid', sql`${t.type} in ('dropdown', 'person', 'checkbox', 'date', 'number', 'text')`),
792
+ check('job_fields_name_valid', sql`length(${t.name}) between 1 and 60`),
793
+ ]);
794
+
795
+ // A job's value for one custom field. A job is the work system's job
796
+ // (source_system, source_job_ref), the same key the Jobs board uses, so a
797
+ // value needs no Flow copy of the job. `value` is JSON: an option id, a person
798
+ // id, true or false, a YYYY-MM-DD date, a number or text. No row means empty.
799
+ export const jobFieldValues = pgTable('job_field_values', {
800
+ organisationId: organisationId().references(() => organisations.id),
801
+ fieldId: uuid('field_id').notNull(),
802
+ sourceSystem: text('source_system').notNull(),
803
+ sourceJobRef: text('source_job_ref').notNull(),
804
+ value: jsonb('value').notNull(),
805
+ updatedBy: uuid('updated_by').references(() => people.id, { onDelete: 'set null' }),
806
+ updatedAt: updatedAt(),
807
+ }, (t) => [
808
+ primaryKey({ columns: [t.organisationId, t.fieldId, t.sourceSystem, t.sourceJobRef] }),
809
+ foreignKey({ name: 'job_field_values_field_same_org', columns: [t.organisationId, t.fieldId], foreignColumns: [jobFields.organisationId, jobFields.id] }).onDelete('cascade'),
810
+ index('job_field_values_job').on(t.organisationId, t.sourceSystem, t.sourceJobRef),
811
+ ]);
812
+
813
+ // Every change of a job's custom field value: who, when, from and to.
814
+ export const jobFieldEvents = pgTable('job_field_events', {
815
+ id: id(),
816
+ organisationId: organisationId().references(() => organisations.id),
817
+ fieldId: uuid('field_id').notNull(),
818
+ sourceSystem: text('source_system').notNull(),
819
+ sourceJobRef: text('source_job_ref').notNull(),
820
+ fromValue: jsonb('from_value'),
821
+ toValue: jsonb('to_value'),
822
+ actor: uuid('actor').references(() => people.id, { onDelete: 'set null' }),
823
+ createdAt: createdAt(),
824
+ }, (t) => [
825
+ foreignKey({ name: 'job_field_events_field_same_org', columns: [t.organisationId, t.fieldId], foreignColumns: [jobFields.organisationId, jobFields.id] }).onDelete('cascade'),
826
+ index('job_field_events_job').on(t.organisationId, t.sourceSystem, t.sourceJobRef, t.createdAt),
827
+ ]);
package/server/index.js CHANGED
@@ -10,7 +10,9 @@ import {
10
10
  importEarlierAtStart, importEarlierLinksAtStart, importEarlierMappingsAtStart, resolveImportAtStart,
11
11
  } from './import/at-start.js';
12
12
  import { createMonitoredLog } from './core/monitoring.js';
13
+ import { createTracing, traceQueries } from './core/tracing.js';
13
14
  import { createPipelineWorker } from './core/pipeline.js';
15
+ import { createReconcileWorker } from './core/reconcile.js';
14
16
  import { createSendWorker } from './core/sending.js';
15
17
  import { resolveSecretsKey } from './core/secrets.js';
16
18
  import { adapters as shippedAdapters } from './integrations/adapters.js';
@@ -77,6 +79,10 @@ await importEarlierMappingsAtStart({ db: database.db, pool: database.pool, setti
77
79
  // own error service while one is on (docs/SENTRY.md).
78
80
  let integrations;
79
81
  const log = createMonitoredLog({ log: console, reporters: () => integrations?.with('captureError') ?? [] });
82
+ // Performance traces, while a tracing service is on (docs/SENTRY.md, "Traces"):
83
+ // each request, and each database query inside one.
84
+ const tracing = createTracing({ tracers: () => integrations?.with('traceRequest') ?? [] });
85
+ traceQueries(database.pool, tracing);
80
86
  try {
81
87
  const stored = await readStoredSettings(database.db);
82
88
  const { env: settingsEnv, unreadable } = settingsEnvironment({ env: process.env, stored, adapters: shippedAdapters, secrets });
@@ -97,6 +103,10 @@ console.log(`[integrations] on: ${integrationsOn.join(', ') || 'none'}; secrets
97
103
  if (process.env.FLOW_PIPELINE !== 'off') {
98
104
  createPipelineWorker({ db: database.db, integrations, log }).start();
99
105
  }
106
+ // The daily reconcile (server/core/reconcile.js) compares Flow's own copies
107
+ // and opens a review item for a day that does not agree. It never calls or
108
+ // writes to another system.
109
+ createReconcileWorker({ db: database.db, integrations, log }).start();
100
110
  // The send worker (server/core/sending.js) sends only what a person queued
101
111
  // with Send all ready, to the accounting integration that is on.
102
112
  createSendWorker({ db: database.db, integrations, log }).start();
@@ -142,7 +152,7 @@ const trustProxy = process.env.REPL_ID || process.env.REPLIT_DEPLOYMENT ? 1 : fa
142
152
 
143
153
  const updates = createUpdateChecker();
144
154
 
145
- createApp({ database, schemaJournals: expectedSchema({ adapters: shippedAdapters }).journals, auth, trustProxy, updates, integrations, secrets, defaultFamily, log }).listen(port, () => {
155
+ createApp({ database, schemaJournals: expectedSchema({ adapters: shippedAdapters }).journals, auth, trustProxy, updates, integrations, secrets, defaultFamily, log, tracing }).listen(port, () => {
146
156
  console.log(`[flow] listening on ${port}; design system ${defaultFamily} (FLOW_THEME)`);
147
157
  });
148
158
 
@@ -11,6 +11,7 @@ import { isMissingTable, migrateAdapterSchema } from './schema.js';
11
11
 
12
12
  export const CAPABILITIES = Object.freeze([
13
13
  'pullWorkItems', // billable work since a cursor (Seazona: completed cases)
14
+ 'readWorkItem', // { state: read | withdrawn | unavailable }: one job read from the work system now, as pullWorkItems gives it (read before act, server/core/read-before-act.js)
14
15
  'pullHistoryJobs', // past work from a history import, as ledger jobs marked historical (Seazona: its API backfill)
15
16
  'pushInvoice', // send an issued invoice; the Flow record id is the idempotency key
16
17
  'pushInvoices', // send several issued invoices in one pass (Send all ready, server/core/sending.js)
@@ -20,16 +21,24 @@ export const CAPABILITIES = Object.freeze([
20
21
  'pushBill', // send an approved bill; the Flow record id is the idempotency key
21
22
  'pullPayments', // payments since a cursor, matched to invoices and bills
22
23
  'checkInvoice', // does the accounting system hold an invoice for a billable job? (the ledger's liveCheck)
24
+ 'billableJobs', // billable jobs by day from the work system's copy in Flow, for the daily reconcile; never a live call
23
25
  'jobReferenceFields', // { patient }: the fields a job gives the invoice Reference (server/core/references.js)
24
- 'listWork', // the work system's jobs from its copy in Flow, open and lately finished, for the Jobs board
26
+ 'listWork', // the work system's jobs from its copy in Flow, open and lately finished, for the Jobs board; a job may carry `pendingMove`
27
+ 'moveWork', // { sourceJobRef, toStatus, personId }: a person moved a job to another stage on the Jobs board; answers what happens (a hand-off when the system's API cannot change it)
28
+ 'cancelMove', // { sourceJobRef }: take back a move the work system has not made yet
29
+ 'personSettings', // { personId }: each person's own choices in this integration, as [{ key, label, hint, kind, value }]
30
+ 'setPersonSetting', // { personId, key, value }: change one, answers the new list
25
31
  'jobIssues', // open issues the integration holds for jobs, by job ref: a short phrase and the page where a person fixes it
26
32
  'listCustomers', // the system's customers, by id and name, for the identity map (Seazona: clients; Xero: contacts)
27
33
  'findInvoices', // sales invoices in the accounting system's copy, for the matcher; never a live call
34
+ 'readChanges', // { state: fresh | read | unavailable }: the accounting system's latest changes into its copy now, just before Flow drafts or sends (read before act)
28
35
  'listPriceItems', // the system's own price list from its copy in Flow, for the price list's links and check; never a live call
29
36
  'health', // connection state for Settings
30
37
  'alerts', // what only a person can clear (a refused sign-in, stopped calls), for the Integrations page; reads Flow's data only
31
38
  'captureError', // an error Flow logged, sent to the installation's own error service (Sentry)
32
39
  'browserMonitoring', // the public settings a browser needs to report errors and problems to that service
40
+ 'traceSpan', // runs work inside a span of the current performance trace (server/core/tracing.js)
41
+ 'traceRequest', // names a request's performance trace by its route, once the route is known
33
42
  ]);
34
43
 
35
44
  const ID_PATTERN = /^[a-z][a-z0-9-]{1,31}$/;
@@ -0,0 +1,48 @@
1
+ import { readBillableOrder } from './feed.js';
2
+ import { readOrder } from './lanes.js';
3
+
4
+ // Read before act (issue #141). Just before Flow drafts or sends from a job
5
+ // whose report is old, it reads that one order from Seazona: one
6
+ // GET orders/{id}, through the call guard, stored through the mirror like
7
+ // any lane read. The answer is the order as the feed gives it:
8
+ //
9
+ // { state: 'read', item } billable; `item` is a feed item
10
+ // { state: 'withdrawn', ledgerWithdrawn, reason }
11
+ // deleted in Seazona, or no longer
12
+ // billable
13
+ // { state: 'unavailable', reason } the read failed or calls are stopped
14
+ //
15
+ // The day's counts are kept in memory for /health; a restart starts them again.
16
+ export function createActReader({ now }) {
17
+ let tally = null;
18
+ function count(key) {
19
+ const day = new Date(Number(now())).toISOString().slice(0, 10);
20
+ if (tally?.day !== day) tally = { day, reads: 0, changed: 0, withdrawn: 0, unavailable: 0 };
21
+ tally[key] += 1;
22
+ }
23
+ const unavailable = (reason) => {
24
+ count('unavailable');
25
+ return { state: 'unavailable', reason };
26
+ };
27
+
28
+ async function read({ db, client, settings }, orderId) {
29
+ count('reads');
30
+ let result;
31
+ try {
32
+ result = await readOrder({ db, client, sync: settings.sync, at: new Date(Number(now())) }, orderId, { known: true });
33
+ } catch (error) {
34
+ return unavailable(error?.message || 'the read failed');
35
+ }
36
+ if (result.outcome === 'failed') return unavailable(result.failure?.error || 'the read failed');
37
+ if (result.summary?.changed || result.summary?.created) count('changed');
38
+ const entry = await readBillableOrder(db, orderId);
39
+ if (!entry) return unavailable('Flow has no copy of the order');
40
+ if (entry.withdrawn) {
41
+ count('withdrawn');
42
+ return { state: 'withdrawn', ledgerWithdrawn: entry.withdrawn.ledgerWithdrawn, reason: entry.withdrawn.reason };
43
+ }
44
+ return { state: 'read', item: entry.item };
45
+ }
46
+
47
+ return { read, status: () => (tally ? { ...tally } : null) };
48
+ }
@@ -1,5 +1,5 @@
1
1
  import { and, desc, eq, gte, isNull, or, sql } from 'drizzle-orm';
2
- import { ledgerLinesFor, toLedgerHold, toLedgerJob } from './ledger.js';
2
+ import { ledgerLinesFor, toLedgerJob, toLedgerWithdrawn } from './ledger.js';
3
3
  import { isFinalStatus, orderStatusLabel, parseSeazonaDate } from './map.js';
4
4
  import { seazonaClients, seazonaInvoices, seazonaOrders, seazonaReference } from './schema.js';
5
5
 
@@ -36,11 +36,9 @@ function encodeCursor(at, id) {
36
36
  return Buffer.from(JSON.stringify({ at, id })).toString('base64url');
37
37
  }
38
38
 
39
- export async function readBillableWork(db, { cursor = null, limit = 200 } = {}) {
40
- const after = decodeCursor(cursor);
41
- const changedAt = sql`greatest(${seazonaOrders.lastChangedAt}, coalesce(${seazonaInvoices.lastChangedAt}, ${seazonaOrders.lastChangedAt}), date_trunc(${SEEN_STEP}, ${seazonaOrders.lastSyncedAt}))`;
42
- const pageSize = Math.max(1, Math.min(1000, limit));
43
- const rows = await db.select({
39
+ // One order, its invoice and its client, as the feed reads them.
40
+ function feedQuery(db, changedAt) {
41
+ return db.select({
44
42
  order: seazonaOrders,
45
43
  invoice: seazonaInvoices,
46
44
  client: { accountNumber: seazonaClients.accountNumber, company: seazonaClients.company, email: seazonaClients.email },
@@ -50,56 +48,70 @@ export async function readBillableWork(db, { cursor = null, limit = 200 } = {})
50
48
  })
51
49
  .from(seazonaOrders)
52
50
  .leftJoin(seazonaInvoices, eq(seazonaInvoices.invoiceNumber, seazonaOrders.invoiceNumber))
53
- .leftJoin(seazonaClients, eq(seazonaClients.id, seazonaOrders.clientId))
51
+ .leftJoin(seazonaClients, eq(seazonaClients.id, seazonaOrders.clientId));
52
+ }
53
+
54
+ // A feed row as an item, or as a withdrawn entry when it is not billable.
55
+ function toFeedEntry({ order, invoice, client, invoiceOrderCount }) {
56
+ const billable = !order.deletedInSourceAt && BILLABLE.test(order.status ?? '');
57
+ if (!billable) {
58
+ const entry = {
59
+ sourceId: order.id,
60
+ status: orderStatusLabel(order),
61
+ reason: order.deletedInSourceAt ? 'deleted_in_seazona' : 'not_billable',
62
+ };
63
+ const syncedAt = order.lastSyncedAt?.toISOString() ?? null;
64
+ return { withdrawn: { ...entry, ledgerWithdrawn: toLedgerWithdrawn({ ...entry, syncedAt }) } };
65
+ }
66
+ const item = {
67
+ source: 'seazona',
68
+ sourceId: order.id,
69
+ invoiceNumber: order.invoiceNumber,
70
+ status: order.status,
71
+ shipped: SHIPPED.test(order.status ?? ''),
72
+ clientId: order.clientId,
73
+ company: order.company,
74
+ doctorName: order.doctorName,
75
+ orderedOn: parseSeazonaDate(order.orderedText),
76
+ dueOn: parseSeazonaDate(order.dueText),
77
+ lines: order.lines,
78
+ subtotalCents: order.subtotalCents,
79
+ patientReference: order.patientReference,
80
+ syncedAt: order.lastSyncedAt.toISOString(),
81
+ invoice: invoice ? {
82
+ invoiceNumber: invoice.invoiceNumber,
83
+ status: invoice.status,
84
+ accountNumber: invoice.accountNumber,
85
+ invoiceDate: parseSeazonaDate(invoice.invoiceDateText),
86
+ dueDate: parseSeazonaDate(invoice.dueText),
87
+ lines: invoice.lines,
88
+ subtotalCents: invoice.subtotalCents,
89
+ taxCents: invoice.taxCents,
90
+ totalCents: invoice.totalCents,
91
+ discountCents: invoice.discountCents,
92
+ patientReference: invoice.patientReference,
93
+ } : null,
94
+ client: client?.accountNumber || client?.company || client?.email ? client : null,
95
+ invoiceOrderCount: order.invoiceNumber ? Number(invoiceOrderCount) : 0,
96
+ };
97
+ return { item: { ...item, linesFrom: ledgerLinesFor(item).linesFrom, ledgerJob: toLedgerJob(item) } };
98
+ }
99
+
100
+ export async function readBillableWork(db, { cursor = null, limit = 200 } = {}) {
101
+ const after = decodeCursor(cursor);
102
+ const changedAt = sql`greatest(${seazonaOrders.lastChangedAt}, coalesce(${seazonaInvoices.lastChangedAt}, ${seazonaOrders.lastChangedAt}), date_trunc(${SEEN_STEP}, ${seazonaOrders.lastSyncedAt}))`;
103
+ const pageSize = Math.max(1, Math.min(1000, limit));
104
+ const rows = await feedQuery(db, changedAt)
54
105
  .where(after ? sql`(${changedAt}, ${seazonaOrders.id}) > (${after.at}::timestamptz, ${after.id})` : undefined)
55
106
  .orderBy(changedAt, seazonaOrders.id)
56
107
  .limit(pageSize);
57
108
 
58
109
  const items = [];
59
110
  const withdrawn = [];
60
- for (const { order, invoice, client, invoiceOrderCount } of rows) {
61
- const billable = !order.deletedInSourceAt && BILLABLE.test(order.status ?? '');
62
- if (!billable) {
63
- const entry = {
64
- sourceId: order.id,
65
- status: orderStatusLabel(order),
66
- reason: order.deletedInSourceAt ? 'deleted_in_seazona' : 'not_billable',
67
- };
68
- withdrawn.push({ ...entry, ledgerHold: toLedgerHold(entry) });
69
- continue;
70
- }
71
- const item = {
72
- source: 'seazona',
73
- sourceId: order.id,
74
- invoiceNumber: order.invoiceNumber,
75
- status: order.status,
76
- shipped: SHIPPED.test(order.status ?? ''),
77
- clientId: order.clientId,
78
- company: order.company,
79
- doctorName: order.doctorName,
80
- orderedOn: parseSeazonaDate(order.orderedText),
81
- dueOn: parseSeazonaDate(order.dueText),
82
- lines: order.lines,
83
- subtotalCents: order.subtotalCents,
84
- patientReference: order.patientReference,
85
- syncedAt: order.lastSyncedAt.toISOString(),
86
- invoice: invoice ? {
87
- invoiceNumber: invoice.invoiceNumber,
88
- status: invoice.status,
89
- accountNumber: invoice.accountNumber,
90
- invoiceDate: parseSeazonaDate(invoice.invoiceDateText),
91
- dueDate: parseSeazonaDate(invoice.dueText),
92
- lines: invoice.lines,
93
- subtotalCents: invoice.subtotalCents,
94
- taxCents: invoice.taxCents,
95
- totalCents: invoice.totalCents,
96
- discountCents: invoice.discountCents,
97
- patientReference: invoice.patientReference,
98
- } : null,
99
- client: client?.accountNumber || client?.company || client?.email ? client : null,
100
- invoiceOrderCount: order.invoiceNumber ? Number(invoiceOrderCount) : 0,
101
- };
102
- items.push({ ...item, linesFrom: ledgerLinesFor(item).linesFrom, ledgerJob: toLedgerJob(item) });
111
+ for (const row of rows) {
112
+ const entry = toFeedEntry(row);
113
+ if (entry.item) items.push(entry.item);
114
+ else withdrawn.push(entry.withdrawn);
103
115
  }
104
116
  const lastRow = rows.at(-1);
105
117
  return {
@@ -110,6 +122,14 @@ export async function readBillableWork(db, { cursor = null, limit = 200 } = {})
110
122
  };
111
123
  }
112
124
 
125
+ // One order as the feed gives it (issue #141, read before act): `{ item }`
126
+ // when it is billable, `{ withdrawn }` when it is not, or null when Flow's
127
+ // copy does not have it.
128
+ export async function readBillableOrder(db, orderId) {
129
+ const [row] = await feedQuery(db, seazonaOrders.lastChangedAt).where(eq(seazonaOrders.id, orderId));
130
+ return row ? toFeedEntry(row) : null;
131
+ }
132
+
113
133
  // Seazona clients, by id and the company name an invoice bills. A client
114
134
  // with no company name is listed by its full name. A client the client list
115
135
  // never gave (the list can be closed to an API client) is listed by the
@@ -68,6 +68,30 @@ export class SeazonaCallsStopped extends CallsStopped {
68
68
  }
69
69
  }
70
70
 
71
+ // A request path with its record ids taken out, so /health can say which
72
+ // calls failed and never names a record: /v1/orders/5591234 is
73
+ // /v1/orders/:id. A name is lowercase words joined by hyphens, or a version.
74
+ export function pathShape(path) {
75
+ const name = /^(v\d+|[a-z]+(-[a-z]+)*)$/;
76
+ return `/${String(path || '').split('/').filter(Boolean).map((part) => (name.test(part) ? part : ':id')).join('/')}`;
77
+ }
78
+
79
+ // Issue #144: the last day's failed calls split into faults (what the call
80
+ // guard counts, and a vendor may count against the API client) and 404s
81
+ // (a record that is gone), and the top groups by path shape, status and
82
+ // kind.
83
+ function failureKinds(day, top = 10) {
84
+ const groups = new Map();
85
+ for (const row of day.failures) {
86
+ const group = { path: pathShape(row.path), statusCode: row.statusCode ?? null, failureKind: row.failureKind ?? null };
87
+ const key = JSON.stringify(group);
88
+ groups.set(key, { ...group, requests: (groups.get(key)?.requests ?? 0) + row.requests });
89
+ }
90
+ const sorted = [...groups.values()].sort((a, b) => b.requests - a.requests).slice(0, top);
91
+ // Every failed call is a fault but a 404 (countsAsFailure, call-guard.js).
92
+ return { faults: day.failed - day.notFound, notFound: day.notFound, failures: sorted };
93
+ }
94
+
71
95
  export function createSeazonaGovernor({
72
96
  db,
73
97
  clientKey,
@@ -171,7 +195,7 @@ export function createSeazonaGovernor({
171
195
  const calls = await guard.status(key);
172
196
  const [{ lastMinute }] = await db.select({ lastMinute: count() }).from(seazonaApiRequests)
173
197
  .where(and(eq(seazonaApiRequests.clientKey, key), gte(seazonaApiRequests.admittedAt, new Date(nowMs - WINDOW_MS))));
174
- const day = await summarise({ from: new Date(nowMs - 24 * 60 * 60_000), to: new Date(nowMs) });
198
+ const day = await summarise({ from: new Date(nowMs - 24 * 60 * 60_000), to: new Date(nowMs), topFailures: 500 });
175
199
  const cooldownUntil = state && state.cooldownUntil.getTime() > nowMs ? state.cooldownUntil.toISOString() : null;
176
200
  return {
177
201
  limitPerMinute: limit,
@@ -184,7 +208,7 @@ export function createSeazonaGovernor({
184
208
  haltedAt: calls.haltedAt,
185
209
  failedToday: calls.failedToday,
186
210
  dailyFailureBudget: calls.dailyFailureBudget,
187
- lastDay: { requests: day.requests, failed: day.failed, rateLimited: day.rateLimited, retries: day.retries },
211
+ lastDay: { requests: day.requests, failed: day.failed, rateLimited: day.rateLimited, retries: day.retries, ...failureKinds(day) },
188
212
  };
189
213
  }
190
214
 
@@ -201,6 +225,7 @@ export function createSeazonaGovernor({
201
225
  failed: sql`count(*) filter (where ${seazonaApiRequests.statusCode} is null or ${seazonaApiRequests.statusCode} >= 400)`,
202
226
  rateLimited: sql`count(*) filter (where ${seazonaApiRequests.statusCode} = 429)`,
203
227
  retries: sql`count(*) filter (where ${seazonaApiRequests.isRetry})`,
228
+ notFound: sql`count(*) filter (where ${seazonaApiRequests.statusCode} = 404)`,
204
229
  }).from(seazonaApiRequests).where(range);
205
230
  const failures = await db.select({
206
231
  path: seazonaApiRequests.requestPath,
@@ -219,6 +244,7 @@ export function createSeazonaGovernor({
219
244
  failed: Number(totals.failed),
220
245
  rateLimited: Number(totals.rateLimited),
221
246
  retries: Number(totals.retries),
247
+ notFound: Number(totals.notFound),
222
248
  failures: failures.map((row) => ({ ...row, requests: Number(row.requests) })),
223
249
  };
224
250
  }
@@ -1,12 +1,18 @@
1
1
  import { guardAlerts } from '../call-guard.js';
2
+ import { createActReader } from './act.js';
2
3
  import { createSeazonaClient } from './client.js';
3
4
  import { listClients, listProducts, listWork, readBillableWork } from './feed.js';
4
5
  import { historyJobIssues, historySummary, readHistoryWork } from './history/index.js';
5
6
  import { readSeazonaSyncHealth, runSeazonaSync } from './lanes.js';
7
+ import { readBillableJobs } from './reconcile.js';
6
8
  import { SEAZONA_CALL_GUARD } from './governor.js';
7
9
  import { createSeazonaRouter } from './routes.js';
10
+ import {
11
+ cancelStageMove, expiredStageMoves, readPersonSettings, requestStageMove, settleStageMoves, waitingStageMoves, writePersonSetting,
12
+ } from './stage-moves.js';
8
13
  import { orderPatient } from './patients.js';
9
14
  import { createSeazonaScheduler } from './scheduler.js';
15
+ import { readSweepHealth } from './sweep.js';
10
16
  import { resetFailedLanes, resetStoppedLanes } from './store.js';
11
17
  import { describeSeazonaSettings, resolveSeazonaSettings } from './settings.js';
12
18
  import { seazonaSchema } from './tables.js';
@@ -45,12 +51,26 @@ export const SEAZONA_SETUP = {
45
51
  { name: 'SEAZONA_API_BASE_URL', label: 'API address', kind: 'url', help: 'The API root of your account, https://…' },
46
52
  { name: 'SEAZONA_API_CLIENT_ID', label: 'Client id' },
47
53
  { name: 'SEAZONA_API_CLIENT_SECRET', label: 'Client secret', secret: true, help: 'Stored encrypted. Flow never shows it again.' },
54
+ { name: 'SEAZONA_ORDER_URL', label: 'Order address', kind: 'url', help: 'Optional. The address of one order in Seazona, with {ref} for the order number. Stage moves on the Jobs board open it.' },
48
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.' },
49
56
  ],
50
57
  };
51
58
 
59
+ // What a person reads for each lane: the Seazona records it copies.
60
+ const READ_NAMES = {
61
+ invoices: 'invoices',
62
+ orders_recent: 'new orders',
63
+ orders_active: 'open orders',
64
+ clients: 'practices',
65
+ products: 'products',
66
+ users: 'lab users',
67
+ history: 'past orders',
68
+ };
69
+ const readNames = (lanes) => lanes.map((lane) => READ_NAMES[lane.lane] ?? lane.lane).join(', ');
70
+
52
71
  export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.now, timers } = {}) {
53
72
  let runtime = null; // { settings, db, client, scheduler }
73
+ const actReader = createActReader({ now });
54
74
 
55
75
  function current() {
56
76
  if (!runtime) throw new Error('The Seazona adapter has not started');
@@ -107,13 +127,17 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
107
127
  if (!runtime) return { ok: false, reason: 'not_started', ...describeSeazonaSettings(null) };
108
128
  const { settings, db, client, scheduler } = runtime;
109
129
  const sync = await readSeazonaSyncHealth(db, settings, { now });
130
+ const schedulerStatus = scheduler ? scheduler.status() : null;
110
131
  return {
111
- ok: sync.ok,
132
+ ok: sync.ok && !schedulerStatus?.stuckSince,
112
133
  ...describeSeazonaSettings(settings),
113
- scheduler: scheduler ? scheduler.status() : null,
134
+ scheduler: schedulerStatus,
114
135
  governor: await client.governor.status(),
115
136
  ...sync,
137
+ // Issue #141: today's reads before a draft or a send.
138
+ readsBeforeAct: actReader.status(),
116
139
  history: await historySummary(db),
140
+ sweep: await readSweepHealth(db, settings, { now }),
117
141
  };
118
142
  },
119
143
 
@@ -122,7 +146,42 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
122
146
  // a person saves the settings. Reads Flow's database only.
123
147
  async alerts() {
124
148
  if (!runtime?.client.governor) return [];
125
- return guardAlerts('Seazona', await runtime.client.governor.status(), { fix: 'Check the API client in the Seazona settings, then save them.' });
149
+ const status = await runtime.client.governor.status();
150
+ const alerts = guardAlerts('Seazona', status, { fix: 'Check the API client in the Seazona settings, then save them.' });
151
+ // Stage moves nobody made in Seazona within a day (stage-moves.js).
152
+ await settleStageMoves(runtime.db, { now: now() });
153
+ const expired = await expiredStageMoves(runtime.db, { now: now() });
154
+ if (expired.length) {
155
+ alerts.push({
156
+ id: 'stage-moves-expired',
157
+ tone: 'warn',
158
+ title: `${expired.length === 1 ? 'One stage move was' : `${expired.length} stage moves were`} not made in Seazona within a day: ${expired.slice(0, 5).map((m) => `${m.orderId} to ${m.toStatus}`).join(', ')}${expired.length > 5 ? ', …' : ''}. The cards went back to their Seazona stage.`,
159
+ members: expired.map((m) => `${m.orderId}:${m.settledAt?.toISOString() ?? ''}`),
160
+ });
161
+ }
162
+ // Issue #143: what the lanes cannot fix by themselves. A refused API
163
+ // client already has its own alert above, so it stops no lane here.
164
+ if (!runtime.settings.scheduled) return alerts;
165
+ const { lanes } = await readSeazonaSyncHealth(runtime.db, runtime.settings, { now });
166
+ const stopped = status.haltedReason ? [] : lanes.filter((lane) => lane.status === 'stopped');
167
+ if (stopped.length) {
168
+ alerts.push({
169
+ id: 'reads-stopped',
170
+ tone: 'danger',
171
+ title: `Seazona refused Flow's request for ${readNames(stopped)}. Flow stopped reading them. Check the Seazona settings, then save them.`,
172
+ members: stopped.map((lane) => `${lane.lane}:${lane.lastAttemptAt ?? ''}`),
173
+ });
174
+ }
175
+ const stale = lanes.filter((lane) => lane.stale && lane.status !== 'stopped');
176
+ if (stale.length) {
177
+ alerts.push({
178
+ id: 'reads-late',
179
+ tone: 'warn',
180
+ title: `Flow has not read ${readNames(stale)} from Seazona on time. Jobs and invoices may show old Seazona data.`,
181
+ members: stale.map((lane) => `${lane.lane}:${lane.lastSuccessAt ?? ''}`),
182
+ });
183
+ }
184
+ return alerts;
126
185
  },
127
186
 
128
187
  // Billable work since a cursor: complete and shipped orders that
@@ -133,12 +192,25 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
133
192
  return readBillableWork(current().db, { cursor, ...options });
134
193
  },
135
194
 
195
+ // Read before act (issue #141): reads one order from Seazona now, and
196
+ // answers it as the feed would (act.js). The only capability here that
197
+ // calls Seazona on a person's or an automation's behalf.
198
+ async readWorkItem({ sourceJobRef }) {
199
+ return actReader.read(current(), sourceJobRef);
200
+ },
201
+
136
202
  // Past work from the history import (docs/SEAZONA.md, "History
137
203
  // import"), as ledger jobs marked `historical`. Reads Flow's copy only.
138
204
  async pullHistoryJobs(cursor = null, options = {}) {
139
205
  return readHistoryWork(current().db, { cursor, ...options });
140
206
  },
141
207
 
208
+ // Billable jobs by day, for the daily reconcile
209
+ // (server/core/reconcile.js). Reads Flow's copy only.
210
+ async billableJobs({ from = null, to = null } = {}) {
211
+ return readBillableJobs(current().db, { from, to });
212
+ },
213
+
142
214
  // Seazona clients by id and company name, for the identity map. Reads
143
215
  // Flow's copy only.
144
216
  async listCustomers() {
@@ -148,7 +220,38 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
148
220
  // Open orders and orders finished lately, with their statuses, for the
149
221
  // Jobs board. Reads Flow's copy only.
150
222
  async listWork() {
151
- return listWork(current().db, { now: now() });
223
+ const { db } = current();
224
+ await settleStageMoves(db, { now: now() });
225
+ const [work, waiting] = await Promise.all([listWork(db, { now: now() }), waitingStageMoves(db)]);
226
+ // A stage move Seazona has not made yet (stage-moves.js): the card
227
+ // shows in its new lane, waiting for Seazona.
228
+ for (const item of work.items) {
229
+ const move = waiting.get(item.sourceJobRef);
230
+ if (move) item.pendingMove = { to: move.toStatus, from: move.fromStatus, requestedAt: move.requestedAt.toISOString(), expiresAt: move.expiresAt.toISOString() };
231
+ }
232
+ return work;
233
+ },
234
+
235
+ // A person's move of a job to another Seazona stage. Seazona's API
236
+ // cannot change an order's status, so this never calls Seazona: it
237
+ // records a hand-off and answers what the person does in Seazona.
238
+ async moveWork({ sourceJobRef, toStatus, personId = null }) {
239
+ const { db, settings } = current();
240
+ return requestStageMove(db, { orderId: sourceJobRef, toStatus, personId, orderUrlTemplate: settings.orderUrlTemplate, now: now() });
241
+ },
242
+
243
+ async cancelMove({ sourceJobRef }) {
244
+ return cancelStageMove(current().db, { orderId: sourceJobRef, now: now() });
245
+ },
246
+
247
+ // Each person's own Seazona choices: today, whether the board asks
248
+ // before a stage move.
249
+ async personSettings({ personId }) {
250
+ return readPersonSettings(current().db, personId);
251
+ },
252
+
253
+ async setPersonSetting({ personId, key, value }) {
254
+ return writePersonSetting(current().db, personId, { key, value }, { now: now() });
152
255
  },
153
256
 
154
257
  // The fields a job gives the invoice Reference: the patient name, when