@arcforgelabs/flow 2026.10.33 → 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 (33) hide show
  1. package/README.md +6 -0
  2. package/npm-shrinkwrap.json +2 -2
  3. package/package.json +1 -1
  4. package/server/api/custom-fields.js +114 -0
  5. package/server/api/invoicing.js +2 -1
  6. package/server/api/work-moves.js +80 -0
  7. package/server/app.js +12 -0
  8. package/server/core/custom-fields.js +300 -0
  9. package/server/core/job-board.js +2 -0
  10. package/server/core/tracing.js +64 -0
  11. package/server/db/migrations/0018_custom_fields.sql +78 -0
  12. package/server/db/migrations/meta/0018_snapshot.json +4289 -0
  13. package/server/db/migrations/meta/_journal.json +7 -0
  14. package/server/db/schema.js +59 -0
  15. package/server/index.js +6 -1
  16. package/server/integrations/port.js +7 -1
  17. package/server/integrations/seazona/index.js +47 -1
  18. package/server/integrations/seazona/migrations/0007_stage_moves.sql +18 -0
  19. package/server/integrations/seazona/migrations/meta/0007_snapshot.json +1519 -0
  20. package/server/integrations/seazona/migrations/meta/_journal.json +7 -0
  21. package/server/integrations/seazona/schema.js +26 -0
  22. package/server/integrations/seazona/settings.js +14 -0
  23. package/server/integrations/seazona/stage-moves.js +135 -0
  24. package/server/integrations/seazona/tables.js +2 -0
  25. package/server/integrations/sentry/index.js +61 -10
  26. package/server/ui/app-page.js +1 -1
  27. package/server/ui/app.js +40 -2
  28. package/server/ui/custom-fields.js +190 -0
  29. package/server/ui/flow.css +48 -9
  30. package/server/ui/invoicing.js +83 -5
  31. package/server/ui/jobs.js +3 -1
  32. package/server/ui/monitoring.js +44 -2
  33. package/server/ui/routes.js +1 -1
@@ -127,6 +127,13 @@
127
127
  "when": 1792458167234,
128
128
  "tag": "0017_sync_basis",
129
129
  "breakpoints": true
130
+ },
131
+ {
132
+ "idx": 18,
133
+ "version": "7",
134
+ "when": 1792472460808,
135
+ "tag": "0018_custom_fields",
136
+ "breakpoints": true
130
137
  }
131
138
  ]
132
139
  }
@@ -766,3 +766,62 @@ export const priceItemLinks = pgTable('price_item_links', {
766
766
  check('price_item_links_matched_by_valid', sql`${t.matchedBy} in ('code', 'person')`),
767
767
  check('price_item_links_system_valid', sql`${t.system} ~ '^[a-z][a-z0-9-]{1,31}$'`),
768
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,6 +10,7 @@ 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';
14
15
  import { createReconcileWorker } from './core/reconcile.js';
15
16
  import { createSendWorker } from './core/sending.js';
@@ -78,6 +79,10 @@ await importEarlierMappingsAtStart({ db: database.db, pool: database.pool, setti
78
79
  // own error service while one is on (docs/SENTRY.md).
79
80
  let integrations;
80
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);
81
86
  try {
82
87
  const stored = await readStoredSettings(database.db);
83
88
  const { env: settingsEnv, unreadable } = settingsEnvironment({ env: process.env, stored, adapters: shippedAdapters, secrets });
@@ -147,7 +152,7 @@ const trustProxy = process.env.REPL_ID || process.env.REPLIT_DEPLOYMENT ? 1 : fa
147
152
 
148
153
  const updates = createUpdateChecker();
149
154
 
150
- 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, () => {
151
156
  console.log(`[flow] listening on ${port}; design system ${defaultFamily} (FLOW_THEME)`);
152
157
  });
153
158
 
@@ -23,7 +23,11 @@ export const CAPABILITIES = Object.freeze([
23
23
  'checkInvoice', // does the accounting system hold an invoice for a billable job? (the ledger's liveCheck)
24
24
  'billableJobs', // billable jobs by day from the work system's copy in Flow, for the daily reconcile; never a live call
25
25
  'jobReferenceFields', // { patient }: the fields a job gives the invoice Reference (server/core/references.js)
26
- '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
27
31
  'jobIssues', // open issues the integration holds for jobs, by job ref: a short phrase and the page where a person fixes it
28
32
  'listCustomers', // the system's customers, by id and name, for the identity map (Seazona: clients; Xero: contacts)
29
33
  'findInvoices', // sales invoices in the accounting system's copy, for the matcher; never a live call
@@ -33,6 +37,8 @@ export const CAPABILITIES = Object.freeze([
33
37
  'alerts', // what only a person can clear (a refused sign-in, stopped calls), for the Integrations page; reads Flow's data only
34
38
  'captureError', // an error Flow logged, sent to the installation's own error service (Sentry)
35
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
36
42
  ]);
37
43
 
38
44
  const ID_PATTERN = /^[a-z][a-z0-9-]{1,31}$/;
@@ -7,6 +7,9 @@ import { readSeazonaSyncHealth, runSeazonaSync } from './lanes.js';
7
7
  import { readBillableJobs } from './reconcile.js';
8
8
  import { SEAZONA_CALL_GUARD } from './governor.js';
9
9
  import { createSeazonaRouter } from './routes.js';
10
+ import {
11
+ cancelStageMove, expiredStageMoves, readPersonSettings, requestStageMove, settleStageMoves, waitingStageMoves, writePersonSetting,
12
+ } from './stage-moves.js';
10
13
  import { orderPatient } from './patients.js';
11
14
  import { createSeazonaScheduler } from './scheduler.js';
12
15
  import { readSweepHealth } from './sweep.js';
@@ -48,6 +51,7 @@ export const SEAZONA_SETUP = {
48
51
  { name: 'SEAZONA_API_BASE_URL', label: 'API address', kind: 'url', help: 'The API root of your account, https://…' },
49
52
  { name: 'SEAZONA_API_CLIENT_ID', label: 'Client id' },
50
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.' },
51
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.' },
52
56
  ],
53
57
  };
@@ -144,6 +148,17 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
144
148
  if (!runtime?.client.governor) return [];
145
149
  const status = await runtime.client.governor.status();
146
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
+ }
147
162
  // Issue #143: what the lanes cannot fix by themselves. A refused API
148
163
  // client already has its own alert above, so it stops no lane here.
149
164
  if (!runtime.settings.scheduled) return alerts;
@@ -205,7 +220,38 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
205
220
  // Open orders and orders finished lately, with their statuses, for the
206
221
  // Jobs board. Reads Flow's copy only.
207
222
  async listWork() {
208
- 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() });
209
255
  },
210
256
 
211
257
  // The fields a job gives the invoice Reference: the patient name, when
@@ -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");