@arcforgelabs/flow 2026.10.33 → 2026.10.35

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 (43) hide show
  1. package/README.md +7 -1
  2. package/npm-shrinkwrap.json +2 -2
  3. package/package.json +2 -1
  4. package/server/api/custom-fields.js +115 -0
  5. package/server/api/invoicing.js +43 -12
  6. package/server/api/work-moves.js +80 -0
  7. package/server/app.js +12 -0
  8. package/server/auth/people.js +35 -0
  9. package/server/auth/routes.js +14 -0
  10. package/server/core/custom-fields.js +308 -0
  11. package/server/core/job-board.js +4 -0
  12. package/server/core/ledger.js +82 -2
  13. package/server/core/tracing.js +64 -0
  14. package/server/db/migrations/0018_custom_fields.sql +78 -0
  15. package/server/db/migrations/0019_people_views.sql +1 -0
  16. package/server/db/migrations/meta/0018_snapshot.json +4289 -0
  17. package/server/db/migrations/meta/0019_snapshot.json +4295 -0
  18. package/server/db/migrations/meta/_journal.json +14 -0
  19. package/server/db/schema.js +64 -1
  20. package/server/index.js +6 -1
  21. package/server/integrations/port.js +8 -1
  22. package/server/integrations/seazona/index.js +60 -2
  23. package/server/integrations/seazona/migrations/0007_stage_moves.sql +18 -0
  24. package/server/integrations/seazona/migrations/meta/0007_snapshot.json +1519 -0
  25. package/server/integrations/seazona/migrations/meta/_journal.json +7 -0
  26. package/server/integrations/seazona/schema.js +26 -0
  27. package/server/integrations/seazona/settings.js +35 -0
  28. package/server/integrations/seazona/stage-moves.js +133 -0
  29. package/server/integrations/seazona/tables.js +2 -0
  30. package/server/integrations/sentry/index.js +61 -10
  31. package/server/release-channel.js +8 -6
  32. package/server/ui/app-page.js +1 -1
  33. package/server/ui/app.js +40 -2
  34. package/server/ui/custom-fields.js +190 -0
  35. package/server/ui/flow.css +128 -10
  36. package/server/ui/invoice-notes.js +1 -1
  37. package/server/ui/invoicing.js +185 -48
  38. package/server/ui/job-board.js +1 -1
  39. package/server/ui/job-change.js +78 -0
  40. package/server/ui/job-view.js +119 -0
  41. package/server/ui/jobs.js +482 -49
  42. package/server/ui/monitoring.js +44 -2
  43. package/server/ui/routes.js +1 -1
@@ -127,6 +127,20 @@
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
137
+ },
138
+ {
139
+ "idx": 19,
140
+ "version": "7",
141
+ "when": 1792474607940,
142
+ "tag": "0019_people_views",
143
+ "breakpoints": true
130
144
  }
131
145
  ]
132
146
  }
@@ -177,7 +177,10 @@ export const PERSON_STATUSES = ['active', 'pending', 'disabled'];
177
177
  // are stored lower-case. `clerk_user_id` is set on the person's first verified
178
178
  // sign-in; until then the row is an invitation. `appearance` is the person's
179
179
  // own design system, light or dark and accent (server/auth/people.js,
180
- // normaliseAppearance); null until they first choose.
180
+ // normaliseAppearance); null until they first choose. `views` is the
181
+ // person's own view of each page, such as how the Jobs page groups, sorts
182
+ // and filters ({ jobs: … }, server/ui/job-view.js); null until they first
183
+ // change one.
181
184
  export const people = pgTable('people', {
182
185
  id: id(),
183
186
  email: text('email').notNull(),
@@ -188,6 +191,7 @@ export const people = pgTable('people', {
188
191
  invitedBy: uuid('invited_by').references(() => people.id, { onDelete: 'set null' }),
189
192
  linkedAt: timestamp('linked_at', { withTimezone: true }),
190
193
  appearance: jsonb('appearance'),
194
+ views: jsonb('views'),
191
195
  createdAt: createdAt(),
192
196
  updatedAt: updatedAt(),
193
197
  }, (t) => [
@@ -766,3 +770,62 @@ export const priceItemLinks = pgTable('price_item_links', {
766
770
  check('price_item_links_matched_by_valid', sql`${t.matchedBy} in ('code', 'person')`),
767
771
  check('price_item_links_system_valid', sql`${t.system} ~ '^[a-z][a-z0-9-]{1,31}$'`),
768
772
  ]);
773
+
774
+ // Custom fields (server/core/custom-fields.js, docs/CUSTOM_FIELDS.md): fields
775
+ // a lab makes for its jobs in Flow, in Settings, Custom fields. Only an owner
776
+ // or admin makes or changes one. A dropdown's options are
777
+ // [{ id, name, color }] in lane order; an option a person removes stays in
778
+ // `options` with `archived: true`, so old values and history still read.
779
+ // The type never changes after a field is made.
780
+ export const CUSTOM_FIELD_TYPES = ['dropdown', 'person', 'checkbox', 'date', 'number', 'text'];
781
+ export const jobFields = pgTable('job_fields', {
782
+ id: id(),
783
+ organisationId: organisationId().references(() => organisations.id),
784
+ name: text('name').notNull(),
785
+ type: text('type').notNull(),
786
+ options: jsonb('options').notNull().default(sql`'[]'::jsonb`),
787
+ position: integer('position').notNull(),
788
+ archivedAt: timestamp('archived_at', { withTimezone: true }),
789
+ createdBy: uuid('created_by').references(() => people.id, { onDelete: 'set null' }),
790
+ updatedBy: uuid('updated_by').references(() => people.id, { onDelete: 'set null' }),
791
+ createdAt: createdAt(),
792
+ updatedAt: updatedAt(),
793
+ }, (t) => [
794
+ unique('job_fields_org_id').on(t.organisationId, t.id),
795
+ check('job_fields_type_valid', sql`${t.type} in ('dropdown', 'person', 'checkbox', 'date', 'number', 'text')`),
796
+ check('job_fields_name_valid', sql`length(${t.name}) between 1 and 60`),
797
+ ]);
798
+
799
+ // A job's value for one custom field. A job is the work system's job
800
+ // (source_system, source_job_ref), the same key the Jobs board uses, so a
801
+ // value needs no Flow copy of the job. `value` is JSON: an option id, a person
802
+ // id, true or false, a YYYY-MM-DD date, a number or text. No row means empty.
803
+ export const jobFieldValues = pgTable('job_field_values', {
804
+ organisationId: organisationId().references(() => organisations.id),
805
+ fieldId: uuid('field_id').notNull(),
806
+ sourceSystem: text('source_system').notNull(),
807
+ sourceJobRef: text('source_job_ref').notNull(),
808
+ value: jsonb('value').notNull(),
809
+ updatedBy: uuid('updated_by').references(() => people.id, { onDelete: 'set null' }),
810
+ updatedAt: updatedAt(),
811
+ }, (t) => [
812
+ primaryKey({ columns: [t.organisationId, t.fieldId, t.sourceSystem, t.sourceJobRef] }),
813
+ foreignKey({ name: 'job_field_values_field_same_org', columns: [t.organisationId, t.fieldId], foreignColumns: [jobFields.organisationId, jobFields.id] }).onDelete('cascade'),
814
+ index('job_field_values_job').on(t.organisationId, t.sourceSystem, t.sourceJobRef),
815
+ ]);
816
+
817
+ // Every change of a job's custom field value: who, when, from and to.
818
+ export const jobFieldEvents = pgTable('job_field_events', {
819
+ id: id(),
820
+ organisationId: organisationId().references(() => organisations.id),
821
+ fieldId: uuid('field_id').notNull(),
822
+ sourceSystem: text('source_system').notNull(),
823
+ sourceJobRef: text('source_job_ref').notNull(),
824
+ fromValue: jsonb('from_value'),
825
+ toValue: jsonb('to_value'),
826
+ actor: uuid('actor').references(() => people.id, { onDelete: 'set null' }),
827
+ createdAt: createdAt(),
828
+ }, (t) => [
829
+ foreignKey({ name: 'job_field_events_field_same_org', columns: [t.organisationId, t.fieldId], foreignColumns: [jobFields.organisationId, jobFields.id] }).onDelete('cascade'),
830
+ index('job_field_events_job').on(t.organisationId, t.sourceSystem, t.sourceJobRef, t.createdAt),
831
+ ]);
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,12 @@ 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
+ 'jobLink', // { url, exact } or null: the link to one job in the work system's own web app, for "Open in" on a job's problem; never a live call
27
+ 'listWork', // the work system's jobs from its copy in Flow, open and lately finished, for the Jobs board; a job may carry `pendingMove`
28
+ '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)
29
+ 'cancelMove', // { sourceJobRef }: take back a move the work system has not made yet
30
+ 'personSettings', // { personId }: each person's own choices in this integration, as [{ key, label, hint, kind, value }]
31
+ 'setPersonSetting', // { personId, key, value }: change one, answers the new list
27
32
  'jobIssues', // open issues the integration holds for jobs, by job ref: a short phrase and the page where a person fixes it
28
33
  'listCustomers', // the system's customers, by id and name, for the identity map (Seazona: clients; Xero: contacts)
29
34
  'findInvoices', // sales invoices in the accounting system's copy, for the matcher; never a live call
@@ -33,6 +38,8 @@ export const CAPABILITIES = Object.freeze([
33
38
  'alerts', // what only a person can clear (a refused sign-in, stopped calls), for the Integrations page; reads Flow's data only
34
39
  'captureError', // an error Flow logged, sent to the installation's own error service (Sentry)
35
40
  'browserMonitoring', // the public settings a browser needs to report errors and problems to that service
41
+ 'traceSpan', // runs work inside a span of the current performance trace (server/core/tracing.js)
42
+ 'traceRequest', // names a request's performance trace by its route, once the route is known
36
43
  ]);
37
44
 
38
45
  const ID_PATTERN = /^[a-z][a-z0-9-]{1,31}$/;
@@ -7,11 +7,14 @@ 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, handOff, 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';
13
16
  import { resetFailedLanes, resetStoppedLanes } from './store.js';
14
- import { describeSeazonaSettings, resolveSeazonaSettings } from './settings.js';
17
+ import { describeSeazonaSettings, orderLink, resolveSeazonaSettings } from './settings.js';
15
18
  import { seazonaSchema } from './tables.js';
16
19
 
17
20
  // The Seazona adapter (docs/SEAZONA.md). FLOW_INTEGRATIONS=seazona turns it
@@ -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. 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.' },
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,44 @@ 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
+ // Each card also carries the link that opens its order in Seazona.
229
+ const { settings } = current();
230
+ for (const item of work.items) {
231
+ item.link = orderLink(settings, { sourceJobRef: item.sourceJobRef });
232
+ const move = waiting.get(item.sourceJobRef);
233
+ if (move) {
234
+ const { to, from, requestedAt, expiresAt, openUrl, openExact } = handOff(move, { settings });
235
+ item.pendingMove = { to, from, requestedAt, expiresAt, openUrl, openExact };
236
+ }
237
+ }
238
+ return work;
239
+ },
240
+
241
+ // A person's move of a job to another Seazona stage. Seazona's API
242
+ // cannot change an order's status, so this never calls Seazona: it
243
+ // records a hand-off and answers what the person does in Seazona.
244
+ async moveWork({ sourceJobRef, toStatus, personId = null }) {
245
+ const { db, settings } = current();
246
+ return requestStageMove(db, { orderId: sourceJobRef, toStatus, personId, settings, now: now() });
247
+ },
248
+
249
+ async cancelMove({ sourceJobRef }) {
250
+ return cancelStageMove(current().db, { orderId: sourceJobRef, now: now() });
251
+ },
252
+
253
+ // Each person's own Seazona choices: today, whether the board asks
254
+ // before a stage move.
255
+ async personSettings({ personId }) {
256
+ return readPersonSettings(current().db, personId);
257
+ },
258
+
259
+ async setPersonSetting({ personId, key, value }) {
260
+ return writePersonSetting(current().db, personId, { key, value }, { now: now() });
209
261
  },
210
262
 
211
263
  // The fields a job gives the invoice Reference: the patient name, when
@@ -221,6 +273,12 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
221
273
  return historyJobIssues(current().db, refs);
222
274
  },
223
275
 
276
+ // The link to one order in Seazona's web app, for "Open in Seazona" on
277
+ // a job's problem (settings.js, orderLink). Never calls Seazona.
278
+ jobLink({ sourceJobRef }) {
279
+ return runtime ? orderLink(runtime.settings, { sourceJobRef }) : null;
280
+ },
281
+
224
282
  // Seazona's products, for the price list (server/core/price-list.js).
225
283
  // Reads Flow's copy only.
226
284
  async listPriceItems() {
@@ -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");