@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.
- package/README.md +7 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
- package/server/api/custom-fields.js +115 -0
- package/server/api/invoicing.js +43 -12
- package/server/api/work-moves.js +80 -0
- package/server/app.js +12 -0
- package/server/auth/people.js +35 -0
- package/server/auth/routes.js +14 -0
- package/server/core/custom-fields.js +308 -0
- package/server/core/job-board.js +4 -0
- package/server/core/ledger.js +82 -2
- package/server/core/tracing.js +64 -0
- package/server/db/migrations/0018_custom_fields.sql +78 -0
- package/server/db/migrations/0019_people_views.sql +1 -0
- package/server/db/migrations/meta/0018_snapshot.json +4289 -0
- package/server/db/migrations/meta/0019_snapshot.json +4295 -0
- package/server/db/migrations/meta/_journal.json +14 -0
- package/server/db/schema.js +64 -1
- package/server/index.js +6 -1
- package/server/integrations/port.js +8 -1
- package/server/integrations/seazona/index.js +60 -2
- 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 +35 -0
- package/server/integrations/seazona/stage-moves.js +133 -0
- package/server/integrations/seazona/tables.js +2 -0
- package/server/integrations/sentry/index.js +61 -10
- package/server/release-channel.js +8 -6
- 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 +128 -10
- package/server/ui/invoice-notes.js +1 -1
- package/server/ui/invoicing.js +185 -48
- package/server/ui/job-board.js +1 -1
- package/server/ui/job-change.js +78 -0
- package/server/ui/job-view.js +119 -0
- package/server/ui/jobs.js +482 -49
- package/server/ui/monitoring.js +44 -2
- 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
|
}
|
package/server/db/schema.js
CHANGED
|
@@ -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
|
-
'
|
|
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
|
-
|
|
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");
|