@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.
- package/README.md +6 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/api/custom-fields.js +114 -0
- package/server/api/invoicing.js +2 -1
- package/server/api/work-moves.js +80 -0
- package/server/app.js +12 -0
- package/server/core/custom-fields.js +300 -0
- package/server/core/job-board.js +2 -0
- package/server/core/tracing.js +64 -0
- package/server/db/migrations/0018_custom_fields.sql +78 -0
- package/server/db/migrations/meta/0018_snapshot.json +4289 -0
- package/server/db/migrations/meta/_journal.json +7 -0
- package/server/db/schema.js +59 -0
- package/server/index.js +6 -1
- package/server/integrations/port.js +7 -1
- package/server/integrations/seazona/index.js +47 -1
- package/server/integrations/seazona/migrations/0007_stage_moves.sql +18 -0
- package/server/integrations/seazona/migrations/meta/0007_snapshot.json +1519 -0
- package/server/integrations/seazona/migrations/meta/_journal.json +7 -0
- package/server/integrations/seazona/schema.js +26 -0
- package/server/integrations/seazona/settings.js +14 -0
- package/server/integrations/seazona/stage-moves.js +135 -0
- package/server/integrations/seazona/tables.js +2 -0
- package/server/integrations/sentry/index.js +61 -10
- 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 +48 -9
- package/server/ui/invoicing.js +83 -5
- package/server/ui/jobs.js +3 -1
- package/server/ui/monitoring.js +44 -2
- package/server/ui/routes.js +1 -1
package/server/db/schema.js
CHANGED
|
@@ -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
|
-
|
|
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");
|