@arcforgelabs/flow 2026.10.50 → 2026.10.52

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 (47) hide show
  1. package/SECURITY.md +9 -4
  2. package/npm-shrinkwrap.json +2 -2
  3. package/package.json +1 -1
  4. package/server/api/captures.js +131 -0
  5. package/server/api/invoicing.js +1 -1
  6. package/server/app.js +15 -0
  7. package/server/auth/people.js +3 -3
  8. package/server/core/ai-bedrock.js +124 -0
  9. package/server/core/ai.js +145 -0
  10. package/server/core/capture-match.js +158 -0
  11. package/server/core/captures.js +501 -0
  12. package/server/core/invoice-rules.js +16 -5
  13. package/server/core/reader.js +324 -0
  14. package/server/core/sending.js +7 -3
  15. package/server/core/storage.js +491 -0
  16. package/server/db/migrations/0023_captures.sql +46 -0
  17. package/server/db/migrations/0024_rule_icon.sql +5 -0
  18. package/server/db/migrations/meta/0023_snapshot.json +4693 -0
  19. package/server/db/migrations/meta/0024_snapshot.json +4703 -0
  20. package/server/db/migrations/meta/_journal.json +14 -0
  21. package/server/db/schema.js +50 -0
  22. package/server/index.js +47 -1
  23. package/server/integrations/seazona/history/backfill.js +39 -9
  24. package/server/integrations/seazona/index.js +1 -12
  25. package/server/integrations/seazona/stage-moves.js +3 -13
  26. package/server/integrations/sentry/index.js +6 -6
  27. package/server/integrations/setup.js +1 -0
  28. package/server/integrations/xero/drafts.js +27 -13
  29. package/server/ui/app-page.js +1 -1
  30. package/server/ui/app.js +101 -8
  31. package/server/ui/capture-fields.js +17 -0
  32. package/server/ui/captures.js +283 -0
  33. package/server/ui/columns.js +68 -3
  34. package/server/ui/flow.css +92 -17
  35. package/server/ui/invoice-rules.js +10 -2
  36. package/server/ui/invoicing-settings.js +2 -8
  37. package/server/ui/invoicing.js +12 -8
  38. package/server/ui/job-view.js +6 -3
  39. package/server/ui/jobs.js +40 -117
  40. package/server/ui/kit.js +86 -1
  41. package/server/ui/list-views.js +47 -3
  42. package/server/ui/mobile.js +45 -5
  43. package/server/ui/nav-pins.js +4 -2
  44. package/server/ui/price-list.js +138 -69
  45. package/server/ui/routes.js +4 -1
  46. package/server/ui/saved-views.js +2 -0
  47. package/server/ui/view-bar.js +61 -2
@@ -1,3 +1,6 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import { dirname, join } from 'node:path';
1
4
  import { and, asc, eq, sql } from 'drizzle-orm';
2
5
  import { invoiceRules, invoicingSettings } from '../db/schema.js';
3
6
  import { FlowError, notFound } from './errors.js';
@@ -26,6 +29,10 @@ const MAX_CONDITIONS = 10;
26
29
  const invalid = (message) => new FlowError('invalid', message);
27
30
 
28
31
  const MAX_NOTE = 200;
32
+ // A rule's own icon: any Lucide name (the icon picker, server/ui/kit.js).
33
+ // Null shows the mark of what the rule does.
34
+ const ICON_DIR = join(dirname(createRequire(import.meta.url).resolve('lucide-static/package.json')), 'icons');
35
+ const ICON_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
29
36
  const day = (date) => new Date(`${String(date).slice(0, 10)}T00:00:00Z`);
30
37
  const norm = (value) => String(value ?? '').trim().replace(/\s+/g, ' ').toLowerCase();
31
38
  // A line's key: its price item code, else the label line mapping gave it (a
@@ -107,10 +114,14 @@ function readCondition(raw, index) {
107
114
 
108
115
  export function readRule(raw) {
109
116
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw invalid('the rule must be an object');
110
- const unknown = Object.keys(raw).filter((k) => !['name', 'enabled', 'conditions', 'action'].includes(k));
117
+ const unknown = Object.keys(raw).filter((k) => !['name', 'icon', 'enabled', 'conditions', 'action'].includes(k));
111
118
  if (unknown.length) throw invalid(`unknown field: ${unknown[0]}`);
112
119
  const name = typeof raw.name === 'string' ? raw.name.trim() : '';
113
120
  if (!name || name.length > 120) throw invalid('the rule needs a name of 120 characters at most');
121
+ const icon = raw.icon ?? null;
122
+ if (icon !== null && (typeof icon !== 'string' || icon.length > 64 || !ICON_NAME.test(icon) || !existsSync(join(ICON_DIR, `${icon}.svg`)))) {
123
+ throw invalid('the icon must be a Lucide icon name');
124
+ }
114
125
  const enabled = raw.enabled ?? true;
115
126
  if (typeof enabled !== 'boolean') throw invalid('enabled must be true or false');
116
127
  if (!Array.isArray(raw.conditions) || !raw.conditions.length || raw.conditions.length > MAX_CONDITIONS) {
@@ -127,7 +138,7 @@ export function readRule(raw) {
127
138
  const early = conditions.find((c) => FIELDS[c.field].draftOnly);
128
139
  if (early) throw invalid(`${FIELDS[early.field].label} is on the draft only: use Then "Pending, with a note"`);
129
140
  }
130
- return { name, enabled, conditions, action };
141
+ return { name, icon, enabled, conditions, action };
131
142
  }
132
143
 
133
144
  function matches(condition, job, context) {
@@ -270,7 +281,7 @@ async function seed(db, organisationId) {
270
281
  }
271
282
 
272
283
  const shape = (r) => ({
273
- id: r.id, name: r.name, position: r.position, enabled: r.enabled, trigger: r.trigger,
284
+ id: r.id, name: r.name, icon: r.icon, position: r.position, enabled: r.enabled, trigger: r.trigger,
274
285
  conditions: r.conditions, action: r.action, starter: Boolean(r.starterKey), updatedAt: r.updatedAt,
275
286
  });
276
287
 
@@ -299,12 +310,12 @@ async function loadRule(db, organisationId, ruleId) {
299
310
  return row;
300
311
  }
301
312
 
302
- // Changes a rule. `changes` holds any of name, enabled, conditions, action;
313
+ // Changes a rule. `changes` holds any of name, icon, enabled, conditions, action;
303
314
  // the result must be a whole valid rule.
304
315
  export async function updateRule(db, organisationId, ruleId, changes, { personId = null } = {}) {
305
316
  const row = await loadRule(db, organisationId, ruleId);
306
317
  if (!changes || typeof changes !== 'object' || Array.isArray(changes)) throw invalid('the change must be an object');
307
- const rule = readRule({ name: row.name, enabled: row.enabled, conditions: row.conditions, action: row.action, ...changes });
318
+ const rule = readRule({ name: row.name, icon: row.icon, enabled: row.enabled, conditions: row.conditions, action: row.action, ...changes });
308
319
  const [saved] = await db.update(invoiceRules).set({ ...rule, updatedBy: personId, updatedAt: sql`now()` })
309
320
  .where(eq(invoiceRules.id, row.id)).returning();
310
321
  return shape(saved);
@@ -0,0 +1,324 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { and, asc, eq, isNull, sql } from 'drizzle-orm';
3
+ import { callCostMicros } from './ai.js';
4
+ import { captures, jobFields } from '../db/schema.js';
5
+ import { FlowError } from './errors.js';
6
+ import { enqueueJob, runNextJob } from './queue.js';
7
+ import { JOB_FIELDS } from '../ui/capture-fields.js';
8
+
9
+ // The vision reader. A small model reads every capture. A larger model reads
10
+ // again only when case number, practice or work is below the sure line.
11
+ // Flow code decides that; the model does not see the jobs list and does not
12
+ // write. The photo and the fields stay out of the log: a failure is a
13
+ // capture id and an error code.
14
+ //
15
+ // A failed call is tried twice more (three calls). The capture is then
16
+ // `problem` with a short reason. Type it still works. The module being off,
17
+ // or the keys missing, is "reader turned off" and is set when the photo is
18
+ // stored (server/core/captures.js), not here.
19
+
20
+ export const CAPTURE_READ_KIND = 'capture.read';
21
+ const CALLS = 3;
22
+ const VALUE_MAX = 2000;
23
+
24
+
25
+ const NEEDED = new Set(JOB_FIELDS.filter((field) => field.needed).map((field) => field.key));
26
+
27
+ // One object, every property required. Bedrock rejects a schema that leaves
28
+ // a property optional past its limit, or that omits additionalProperties: false.
29
+ const FIELD_SCHEMA = Object.freeze({
30
+ type: 'object',
31
+ properties: {
32
+ fields: {
33
+ type: 'array',
34
+ items: {
35
+ type: 'object',
36
+ properties: {
37
+ key: { type: 'string' },
38
+ value: { type: 'string' },
39
+ seen: { type: 'string' },
40
+ sure: { type: 'number' },
41
+ },
42
+ required: ['key', 'value', 'seen', 'sure'],
43
+ additionalProperties: false,
44
+ },
45
+ },
46
+ },
47
+ required: ['fields'],
48
+ additionalProperties: false,
49
+ });
50
+
51
+ const PROBLEM = new Set([
52
+ 'the reader did not answer',
53
+ 'could not read the form',
54
+ 'photo type cannot be read',
55
+ 'the photo is missing',
56
+ ]);
57
+
58
+ const READABLE = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
59
+
60
+ function problemText(error) {
61
+ return PROBLEM.has(error?.message) ? error.message : 'the reader did not answer';
62
+ }
63
+
64
+ function blank(key) {
65
+ return { key, value: '', seen: '', sure: 0 };
66
+ }
67
+
68
+ function clip(value) {
69
+ return value.trim().slice(0, VALUE_MAX);
70
+ }
71
+
72
+ function sureOf(value) {
73
+ const number = typeof value === 'number' ? value : Number(value);
74
+ if (!Number.isFinite(number)) return 0;
75
+ return Math.min(1, Math.max(0, number));
76
+ }
77
+
78
+ // One field per key the lab asked for, in that order. A key that was not
79
+ // asked for is dropped, so a patient name the model adds is not stored.
80
+ // A value that is not text is empty: an unreadable field is never a guess.
81
+ function parseFields(text, fieldList) {
82
+ let parsed;
83
+ try {
84
+ parsed = JSON.parse(text);
85
+ } catch {
86
+ const error = new FlowError('reader_unreadable', 'could not read the form');
87
+ error.retry = true;
88
+ throw error;
89
+ }
90
+ const rows = Array.isArray(parsed?.fields) ? parsed.fields : null;
91
+ if (!rows) {
92
+ const error = new FlowError('reader_unreadable', 'could not read the form');
93
+ error.retry = true;
94
+ throw error;
95
+ }
96
+ const byKey = new Map();
97
+ for (const row of rows) {
98
+ if (!row || typeof row.key !== 'string' || !row.key) continue;
99
+ if (typeof row.value !== 'string' || typeof row.seen !== 'string') {
100
+ byKey.set(row.key, blank(row.key));
101
+ continue;
102
+ }
103
+ byKey.set(row.key, { key: row.key, value: clip(row.value), seen: clip(row.seen), sure: sureOf(row.sure) });
104
+ }
105
+ return fieldList.map((field) => byKey.get(field.key) ?? blank(field.key));
106
+ }
107
+
108
+ function belowLine(fields, line) {
109
+ const byKey = new Map(fields.map((field) => [field.key, field]));
110
+ for (const key of NEEDED) {
111
+ if ((byKey.get(key)?.sure ?? 0) < line) return true;
112
+ }
113
+ return false;
114
+ }
115
+
116
+ function recorded(fields, calls, model = calls.at(-1).model) {
117
+ const inputTokens = calls.reduce((sum, call) => sum + call.inputTokens, 0);
118
+ const outputTokens = calls.reduce((sum, call) => sum + call.outputTokens, 0);
119
+ const costMicros = calls.some((call) => call.costMicros == null)
120
+ ? null
121
+ : calls.reduce((sum, call) => sum + call.costMicros, 0);
122
+ return { fields, model, inputTokens, outputTokens, costMicros, calls };
123
+ }
124
+
125
+ function instructionFor(fieldList) {
126
+ const lines = fieldList.map((field) => `- ${field.key}: ${field.label}`).join('\n');
127
+ return [
128
+ 'Read this photo of a handwritten lab form.',
129
+ 'Return one field for each key below.',
130
+ 'value is what should be stored. Use an empty string when you cannot read it. Never guess a name, a number or a date.',
131
+ 'seen is the exact text you saw, or an empty string when nothing was written.',
132
+ 'sure is a number from 0 to 1: how sure you are that value matches what was written.',
133
+ 'Ignore printed lab branding.',
134
+ 'Do not add a key that is not in this list.',
135
+ lines,
136
+ ].join('\n');
137
+ }
138
+
139
+ async function captureFields(db, organisationId) {
140
+ const rows = await db.select({ id: jobFields.id, name: jobFields.name }).from(jobFields).where(and(
141
+ eq(jobFields.organisationId, organisationId),
142
+ isNull(jobFields.archivedAt),
143
+ )).orderBy(asc(jobFields.position), asc(jobFields.name));
144
+ const labels = new Set(JOB_FIELDS.map((field) => field.label.toLowerCase()));
145
+ const custom = rows
146
+ .filter((row) => row.name && !labels.has(row.name.trim().toLowerCase()))
147
+ .map((row) => ({ key: `custom_${row.id}`, label: row.name.trim(), needed: false }));
148
+ return [...JOB_FIELDS, ...custom];
149
+ }
150
+
151
+ // Three calls at most. A call that returned token counts is kept on the
152
+ // error when every attempt fails, so the capture still records the cost.
153
+ async function ask(port, { model, price, fieldList, mediaType, bytes, instruction }) {
154
+ const spent = [];
155
+ let last = new FlowError('reader_unavailable', 'the reader did not answer');
156
+ for (let attempt = 0; attempt < CALLS; attempt += 1) {
157
+ try {
158
+ const result = await port.readImage({
159
+ model, mediaType, bytes, instruction, schema: FIELD_SCHEMA, maxTokens: 4096,
160
+ });
161
+ spent.push({
162
+ model,
163
+ inputTokens: result.inputTokens,
164
+ outputTokens: result.outputTokens,
165
+ costMicros: callCostMicros({ inputTokens: result.inputTokens, outputTokens: result.outputTokens, price }),
166
+ });
167
+ return { fields: parseFields(result.text, fieldList), spent };
168
+ } catch (error) {
169
+ last = error;
170
+ if (error?.retry === false) break;
171
+ }
172
+ }
173
+ last.spent = spent;
174
+ throw last;
175
+ }
176
+
177
+ async function mark(db, row, fields) {
178
+ const [next] = await db.update(captures).set({ ...fields, updatedAt: sql`now()` }).where(and(
179
+ eq(captures.organisationId, row.organisationId),
180
+ eq(captures.id, row.id),
181
+ eq(captures.state, 'reading'),
182
+ )).returning();
183
+ return next ?? row;
184
+ }
185
+
186
+ // Reads one capture that is still `reading`. A capture that has moved on
187
+ // (typed, decided) is left as it is. The model's answer is stored on the
188
+ // row and is not written to the log.
189
+ export async function readStoredCapture(db, storage, port, settings, { organisationId, captureId, log = console }) {
190
+ const [row] = await db.select().from(captures).where(and(
191
+ eq(captures.organisationId, organisationId),
192
+ eq(captures.id, captureId),
193
+ ));
194
+ if (!row || row.state !== 'reading') return row ?? null;
195
+
196
+ let stored;
197
+ try {
198
+ stored = await storage.get(row.fileKey);
199
+ } catch {
200
+ const error = new FlowError('reader_unavailable', 'the reader did not answer');
201
+ error.retry = true;
202
+ throw error;
203
+ }
204
+ if (!stored) {
205
+ log.error?.(`[reader] ${row.id} reader_missing`);
206
+ return mark(db, row, { state: 'problem', problem: 'the photo is missing' });
207
+ }
208
+ const sha = createHash('sha256').update(stored.body).digest('hex');
209
+ if (sha !== row.fileSha256) {
210
+ log.error?.(`[reader] ${row.id} hash_mismatch`);
211
+ return mark(db, row, { state: 'problem', problem: 'could not read the form' });
212
+ }
213
+ if (!READABLE.has(row.fileType)) {
214
+ log.error?.(`[reader] ${row.id} reader_type`);
215
+ return mark(db, row, { state: 'problem', problem: 'photo type cannot be read' });
216
+ }
217
+
218
+ const fieldList = await captureFields(db, organisationId);
219
+ const instruction = instructionFor(fieldList);
220
+ const photo = { fieldList, mediaType: row.fileType, bytes: stored.body, instruction };
221
+ const calls = [];
222
+ let fields = null;
223
+ try {
224
+ const first = await ask(port, { ...photo, model: settings.model, price: settings.prices.small });
225
+ calls.push(...first.spent);
226
+ fields = first.fields;
227
+ if (belowLine(fields, settings.sureLine)) {
228
+ const second = await ask(port, { ...photo, model: settings.large, price: settings.prices.large });
229
+ calls.push(...second.spent);
230
+ fields = second.fields;
231
+ }
232
+ return mark(db, row, { state: 'ready', problem: null, read: recorded(fields, calls) });
233
+ } catch (error) {
234
+ log.error?.(`[reader] ${row.id} ${error?.code ?? 'reader_unavailable'}`);
235
+ calls.push(...(error?.spent ?? []));
236
+ return mark(db, row, {
237
+ state: 'problem',
238
+ problem: problemText(error),
239
+ read: calls.length ? recorded(fields ?? [], calls, fields ? settings.model : calls.at(-1).model) : null,
240
+ });
241
+ }
242
+ }
243
+
244
+ // Queues the read, or runs it in the request when background jobs are off
245
+ // (a Replit workspace). The worker only claims `capture.read` jobs. Two
246
+ // processes can run: the lease keeps a capture to one of them.
247
+ export function createCaptureReader({
248
+ db, storage, port, settings, log = console, jobsOn = true, timers = globalThis,
249
+ }) {
250
+ async function handle(job) {
251
+ const captureId = job.payload?.captureId ?? null;
252
+ try {
253
+ await readStoredCapture(db, storage, port, settings, { organisationId: job.organisationId, captureId, log });
254
+ return { captureId };
255
+ } catch (error) {
256
+ log.error?.(`[reader] ${captureId ?? '-'} ${error?.code ?? 'reader_unavailable'}`);
257
+ const safe = new FlowError('reader_unavailable', 'the reader did not answer');
258
+ safe.retry = error?.retry !== false;
259
+ throw safe;
260
+ }
261
+ }
262
+
263
+ async function schedule(row) {
264
+ if (!settings?.on) return row;
265
+ if (!jobsOn) {
266
+ return readStoredCapture(db, storage, port, settings, { organisationId: row.organisationId, captureId: row.id, log });
267
+ }
268
+ await enqueueJob(db, row.organisationId, {
269
+ kind: CAPTURE_READ_KIND,
270
+ dedupeKey: row.id,
271
+ payload: { captureId: row.id },
272
+ maxAttempts: 2,
273
+ });
274
+ return row;
275
+ }
276
+
277
+ const workerId = `reader-${randomUUID()}`;
278
+ let timer = null;
279
+ let stopped = true;
280
+ let running = null;
281
+
282
+ function arm(delay) {
283
+ if (stopped) return;
284
+ timer = timers.setTimeout(async () => {
285
+ timer = null;
286
+ running = tick();
287
+ const again = await running;
288
+ running = null;
289
+ arm(again ? 0 : 1000);
290
+ }, delay);
291
+ timer?.unref?.();
292
+ }
293
+
294
+ async function tick() {
295
+ try {
296
+ const ran = await runNextJob(db, {
297
+ workerId,
298
+ leaseMs: 15 * 60 * 1000,
299
+ handlers: { [CAPTURE_READ_KIND]: handle },
300
+ });
301
+ return Boolean(ran);
302
+ } catch {
303
+ log.error?.('[reader] - reader_unavailable');
304
+ return false;
305
+ }
306
+ }
307
+
308
+ return {
309
+ schedule,
310
+ handle,
311
+ worker: {
312
+ start() {
313
+ stopped = false;
314
+ arm(1000);
315
+ },
316
+ async stop() {
317
+ stopped = true;
318
+ if (timer) timers.clearTimeout(timer);
319
+ timer = null;
320
+ await running;
321
+ },
322
+ },
323
+ };
324
+ }
@@ -53,6 +53,10 @@ const SENT = new Set(['created', 'reconciled', 'existing', 'already_in_xero']);
53
53
  // it. `extra` adds `fields` (the invoice fields to fix) and `line`.
54
54
  const problem = (code, message, short = null, extra = {}) => ({ code, message, ...(short ? { short } : {}), ...extra });
55
55
 
56
+ // An issued invoice the accounting system refused. A refusal stored without
57
+ // its few words (before 2026-10-28) shows "Not sent"; its message stays.
58
+ const refusal = (sendError) => (sendError ? { short: 'Not sent', ...sendError } : problem('send_failed', 'Sending failed.', 'Not sent'));
59
+
56
60
  // Flow's own checks of one draft. `lines` are its invoice lines.
57
61
  export function draftProblems(invoice, lines) {
58
62
  const problems = [];
@@ -155,7 +159,7 @@ export async function invoiceViews(db, organisationId, { accounting = null } = {
155
159
  .orderBy(desc(invoices.sendUpdatedAt));
156
160
  for (const row of issued) {
157
161
  if (row.sendState === 'queued') sending.add(row.id);
158
- else problems.set(row.id, [row.sendError ?? problem('send_failed', 'Sending failed.', 'Send failed')]);
162
+ else problems.set(row.id, [refusal(row.sendError)]);
159
163
  }
160
164
  // A draft whose checks pass is ready (Samuel, 2026-10-20): no person
161
165
  // needs to mark it. toCheck stays for callers that still read it.
@@ -174,7 +178,7 @@ export async function invoiceView(db, organisationId, invoice, { accounting = nu
174
178
  if (invoice.status === 'void') return { view: null, problems: [] };
175
179
  if (invoice.status === 'issued') {
176
180
  if (invoice.sendState === 'queued') return { view: 'sending', problems: [] };
177
- if (invoice.sendState === 'failed') return { view: 'problem', problems: [invoice.sendError ?? problem('send_failed', 'Sending failed.', 'Send failed')] };
181
+ if (invoice.sendState === 'failed') return { view: 'problem', problems: [refusal(invoice.sendError)] };
178
182
  return { view: null, problems: [] };
179
183
  }
180
184
  const found = (await checkDrafts(db, organisationId, [invoice], accounting)).get(invoice.id) ?? [];
@@ -435,7 +439,7 @@ export async function sendQueued(db, organisationId, { accounting, limit = WORKE
435
439
  summary.waiting += 1;
436
440
  } else {
437
441
  const error = result.error ?? { code: `send_${result.outcome}`, message: `Sending ended as ${result.outcome}.` };
438
- await settle(db, organisationId, result.invoiceId, { sendState: 'failed', sendError: { code: error.code, message: error.message } });
442
+ await settle(db, organisationId, result.invoiceId, { sendState: 'failed', sendError: { code: error.code, message: error.message, ...(error.short ? { short: error.short } : {}) } });
439
443
  summary.failed += 1;
440
444
  summary.refused = [...new Set([...(summary.refused ?? []), error.code])];
441
445
  const fact = refusalFact(known.get(result.invoiceId), error);