@arcforgelabs/flow 2026.10.49 → 2026.10.51

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.
@@ -0,0 +1,397 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { and, eq, isNotNull, isNull, lt, sql } from 'drizzle-orm';
3
+ import { CAPTURE_DECISIONS, captures } from '../db/schema.js';
4
+ import { FlowError, notFound } from './errors.js';
5
+
6
+ // Shown when the module is off or the keys are missing. Type it still works.
7
+ const READER_OFF = 'reader turned off';
8
+
9
+ // A capture is one photo of a handwritten lab form (docs/DATA_MODEL.md,
10
+ // "Storage"). This does not change a job. The reader, when it is on, fills
11
+ // `read` in the background (server/core/reader.js). When it is off, or the
12
+ // keys are missing, the capture is `problem` ("reader turned off") and a
13
+ // person can type the proposal. Apply or Discard records that decision and
14
+ // deletes the photo. A capture nobody decides is deleted after 7 days,
15
+ // photo included.
16
+ //
17
+ // Nothing here writes the photo, the typed fields or a patient name to a log.
18
+ // Callers log a capture id and an error code only.
19
+
20
+ export const MAX_PHOTO_BYTES = 12 * 1024 * 1024;
21
+ export const CAPTURE_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
22
+ export const EMPTY_PROPOSAL = Object.freeze({ action: null, jobId: null, changes: [] });
23
+
24
+ const PHOTO_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'image/heif']);
25
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
26
+ const ACTIONS = new Set(['update', 'create', 'note']);
27
+ const FIELD_MAX = 60;
28
+ const VALUE_MAX = 2000;
29
+ const CHANGES_MAX = 40;
30
+
31
+ const invalid = (message) => new FlowError('invalid', message);
32
+
33
+ export function readPhoto(bytes, contentType) {
34
+ // A request body can arrive as a string or a list. A photo is a buffer.
35
+ // The type check comes before any read of the bytes.
36
+ if (typeof bytes === 'string' || Array.isArray(bytes) || !Buffer.isBuffer(bytes)) throw invalid('photo is required');
37
+ const type = String(contentType ?? '').split(';')[0].trim().toLowerCase();
38
+ const fileType = type === 'image/jpg' ? 'image/jpeg' : type;
39
+ if (!PHOTO_TYPES.has(fileType)) throw new FlowError('unsupported_media', 'photo must be a jpeg, png, webp or heic');
40
+ if (bytes.length === 0) throw invalid('photo is required');
41
+ if (bytes.length > MAX_PHOTO_BYTES) throw new FlowError('photo_too_large', 'photo is too large');
42
+ if (!looksLikePhoto(fileType, bytes)) throw new FlowError('not_a_photo', 'photo could not be read');
43
+ return { fileType, fileSize: bytes.length, fileSha256: createHash('sha256').update(bytes).digest('hex') };
44
+ }
45
+
46
+ function looksLikePhoto(type, bytes) {
47
+ if (type === 'image/jpeg') return bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
48
+ if (type === 'image/png') return bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
49
+ if (type === 'image/webp') return bytes.length >= 12 && bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP';
50
+ if (bytes.length < 12 || bytes.toString('ascii', 4, 8) !== 'ftyp') return false;
51
+ const brand = bytes.toString('ascii', 8, 12);
52
+ return ['heic', 'heix', 'hevc', 'hevx', 'heim', 'heis', 'heif', 'mif1', 'msf1'].includes(brand);
53
+ }
54
+
55
+ export function readIdempotencyKey(raw) {
56
+ const key = String(raw ?? '').trim();
57
+ if (!key || key.length > 200 || /[\u0000-\u001f\u007f]/.test(key)) throw invalid('idempotency key is required');
58
+ return key;
59
+ }
60
+
61
+ export function readSource(raw) {
62
+ const source = String(raw ?? 'api').trim().toLowerCase() || 'api';
63
+ if (!['phone', 'desk', 'api'].includes(source)) throw invalid('source must be phone, desk or api');
64
+ return source;
65
+ }
66
+
67
+ function readText(value, max) {
68
+ if (typeof value !== 'string') throw invalid('a change must be text');
69
+ const trimmed = value.trim();
70
+ if (trimmed.length > max) throw invalid('a change is too long');
71
+ return trimmed;
72
+ }
73
+
74
+ // What a person typed. The values are stored on the capture and returned to
75
+ // that person. They are not written to a log.
76
+ export function readProposal(input) {
77
+ if (input == null) return { ...EMPTY_PROPOSAL, changes: [] };
78
+ const action = input.action ?? null;
79
+ if (action !== null && !ACTIONS.has(action)) throw invalid('action must be update, create or note');
80
+ const jobId = input.jobId ?? null;
81
+ if (jobId !== null && !UUID.test(String(jobId))) throw invalid('jobId must be a job id');
82
+ if (!Array.isArray(input.changes)) throw invalid('changes must be a list');
83
+ if (input.changes.length > CHANGES_MAX) throw invalid('too many changes');
84
+ const changes = input.changes.map((change) => ({
85
+ field: readText(change?.field, FIELD_MAX),
86
+ from: readText(change?.from ?? '', VALUE_MAX),
87
+ to: readText(change?.to ?? '', VALUE_MAX),
88
+ }));
89
+ if (changes.some((change) => !change.field)) throw invalid('a change needs a field');
90
+ if (changes.length > 0 && !action) throw invalid('action is required');
91
+ return { action, jobId, changes };
92
+ }
93
+
94
+ export function captureView(row, link = null) {
95
+ return {
96
+ id: row.id,
97
+ organisationId: row.organisationId,
98
+ state: row.state,
99
+ source: row.source,
100
+ fileType: row.fileType,
101
+ fileSize: row.fileSize,
102
+ fileSha256: row.fileSha256,
103
+ proposal: row.proposal ?? { ...EMPTY_PROPOSAL, changes: [] },
104
+ read: row.read ?? null,
105
+ problem: row.problem,
106
+ decision: row.decision,
107
+ decidedAt: row.decidedAt,
108
+ photoDeletedAt: row.photoDeletedAt,
109
+ createdAt: row.createdAt,
110
+ link,
111
+ };
112
+ }
113
+
114
+ function linkFor(storage, row, baseUrl) {
115
+ if (!row || row.photoDeletedAt) return null;
116
+ return storage.signedLink({ key: row.fileKey, baseUrl });
117
+ }
118
+
119
+ async function byId(db, organisationId, id) {
120
+ if (!UUID.test(String(id ?? ''))) throw notFound('capture');
121
+ const [row] = await db.select().from(captures).where(and(
122
+ eq(captures.organisationId, organisationId),
123
+ eq(captures.id, id),
124
+ ));
125
+ if (!row) throw notFound('capture');
126
+ return row;
127
+ }
128
+
129
+ async function byIdempotency(db, organisationId, idempotencyKey) {
130
+ const [row] = await db.select().from(captures).where(and(
131
+ eq(captures.organisationId, organisationId),
132
+ eq(captures.idempotencyKey, idempotencyKey),
133
+ ));
134
+ return row ?? null;
135
+ }
136
+
137
+ // The first body stored for an idempotency key wins. A repeat upload, even
138
+ // with different bytes, returns that capture and stores nothing else. If the
139
+ // process stops after the bytes are stored and before the row is inserted,
140
+ // the object is orphaned: its key is a random id and it is not a capture.
141
+ export async function uploadCapture(db, storage, organisationId, {
142
+ bytes, contentType, idempotencyKey, source = 'api', personId, baseUrl, reader = null,
143
+ }) {
144
+ const photo = readPhoto(bytes, contentType);
145
+ const key = readIdempotencyKey(idempotencyKey);
146
+ const from = readSource(source);
147
+ const existing = await byIdempotency(db, organisationId, key);
148
+ if (existing) {
149
+ // The read is queued after the row is inserted. A repeat of the same
150
+ // key starts it if that queue step did not finish. It does not read a
151
+ // capture that already has a result.
152
+ if (existing.state === 'reading' && reader?.on) {
153
+ const settled = await reader.schedule(existing);
154
+ return { capture: settled ?? existing, created: false, link: linkFor(storage, settled ?? existing, baseUrl) };
155
+ }
156
+ return { capture: existing, created: false, link: linkFor(storage, existing, baseUrl) };
157
+ }
158
+
159
+ const id = randomUUID();
160
+ const fileKey = `captures/${String(organisationId).toLowerCase()}/${id}`;
161
+ try {
162
+ await storage.put({ key: fileKey, body: bytes, contentType: photo.fileType });
163
+ } catch (error) {
164
+ if (error instanceof FlowError) throw error;
165
+ throw new FlowError('storage_unavailable', 'the photo could not be stored');
166
+ }
167
+ try {
168
+ const reading = Boolean(reader?.on);
169
+ const [row] = await db.insert(captures).values({
170
+ id,
171
+ organisationId,
172
+ state: reading ? 'reading' : 'problem',
173
+ source: from,
174
+ fileKey,
175
+ fileType: photo.fileType,
176
+ fileSize: photo.fileSize,
177
+ fileSha256: photo.fileSha256,
178
+ idempotencyKey: key,
179
+ proposal: { ...EMPTY_PROPOSAL, changes: [] },
180
+ problem: reading ? null : READER_OFF,
181
+ createdBy: personId ?? null,
182
+ }).onConflictDoNothing({ target: [captures.organisationId, captures.idempotencyKey] }).returning();
183
+ if (row) {
184
+ // With background jobs off, schedule reads before it returns, so the
185
+ // response is the finished capture. With jobs on, the row stays `reading`.
186
+ const settled = reading ? await reader.schedule(row) : row;
187
+ return { capture: settled ?? row, created: true, link: linkFor(storage, settled ?? row, baseUrl) };
188
+ }
189
+ } catch (error) {
190
+ await storage.delete(fileKey).catch(() => {});
191
+ if (error instanceof FlowError) throw error;
192
+ throw new FlowError('storage_unavailable', 'the photo could not be stored');
193
+ }
194
+ // Another request stored this key first. Drop the bytes we just wrote.
195
+ // Their key is this attempt's id, not the winner's.
196
+ await storage.delete(fileKey).catch(() => {});
197
+ const winner = await byIdempotency(db, organisationId, key);
198
+ if (!winner) throw new FlowError('conflict', 'this upload was already stored');
199
+ return { capture: winner, created: false, link: linkFor(storage, winner, baseUrl) };
200
+ }
201
+
202
+ export async function readCapture(db, storage, organisationId, id, baseUrl) {
203
+ const row = await byId(db, organisationId, id);
204
+ return { capture: row, link: linkFor(storage, row, baseUrl) };
205
+ }
206
+
207
+ export async function typeProposal(db, storage, organisationId, id, input, { baseUrl } = {}) {
208
+ const proposal = readProposal(input);
209
+ await byId(db, organisationId, id);
210
+ const [row] = await db.update(captures).set({
211
+ state: 'ready',
212
+ problem: null,
213
+ proposal,
214
+ updatedAt: sql`now()`,
215
+ }).where(and(
216
+ eq(captures.organisationId, organisationId),
217
+ eq(captures.id, id),
218
+ sql`${captures.state} in ('ready', 'problem')`,
219
+ )).returning();
220
+ if (!row) throw new FlowError('conflict', 'this capture is not ready');
221
+ return { capture: row, link: linkFor(storage, row, baseUrl) };
222
+ }
223
+
224
+ async function deletePhoto(db, storage, row) {
225
+ if (row.photoDeletedAt) return row;
226
+ try {
227
+ await storage.delete(row.fileKey);
228
+ } catch (error) {
229
+ const failure = error instanceof FlowError ? error : new FlowError('storage_unavailable', 'the photo could not be deleted');
230
+ failure.captureId = row.id;
231
+ if (!failure.code) failure.code = 'storage_unavailable';
232
+ throw failure;
233
+ }
234
+ const [next] = await db.update(captures).set({
235
+ photoDeletedAt: sql`now()`,
236
+ updatedAt: sql`now()`,
237
+ }).where(and(
238
+ eq(captures.organisationId, row.organisationId),
239
+ eq(captures.id, row.id),
240
+ isNull(captures.photoDeletedAt),
241
+ )).returning();
242
+ return next ?? row;
243
+ }
244
+
245
+ // Records the person's decision and deletes the photo. It does not change a
246
+ // job: matching and applying a change are later slices. The same decision
247
+ // again deletes the photo if that was still waiting.
248
+ export async function decideCapture(db, storage, organisationId, id, decision, { personId, baseUrl } = {}) {
249
+ if (!CAPTURE_DECISIONS.includes(decision)) throw invalid('decision must be applied or discarded');
250
+ const current = await byId(db, organisationId, id);
251
+ if (current.state === 'done') {
252
+ if (current.decision !== decision) throw new FlowError('conflict', 'this capture already has a decision');
253
+ const row = await deletePhoto(db, storage, current);
254
+ return { capture: row, link: null };
255
+ }
256
+ if (current.state !== 'ready') throw new FlowError('conflict', 'this capture is not ready');
257
+ const [row] = await db.update(captures).set({
258
+ state: 'done',
259
+ decision,
260
+ decidedBy: personId ?? null,
261
+ decidedAt: sql`now()`,
262
+ updatedAt: sql`now()`,
263
+ }).where(and(
264
+ eq(captures.organisationId, organisationId),
265
+ eq(captures.id, id),
266
+ eq(captures.state, 'ready'),
267
+ )).returning();
268
+ if (!row) {
269
+ const again = await byId(db, organisationId, id);
270
+ if (again.state === 'done' && again.decision === decision) {
271
+ return { capture: await deletePhoto(db, storage, again), link: null };
272
+ }
273
+ throw new FlowError('conflict', again.state === 'done' ? 'this capture already has a decision' : 'this capture is not ready');
274
+ }
275
+ const deleted = await deletePhoto(db, storage, row);
276
+ return { capture: deleted, link: null };
277
+ }
278
+
279
+ // Serves a signed Flow link. The checksum is checked before any byte is
280
+ // returned. An S3 link is not served here; S3 honours that URL itself.
281
+ export async function readCapturePhoto(db, storage, token) {
282
+ const opened = storage.openLink(String(token ?? ''));
283
+ if (!opened.ok) {
284
+ const error = new FlowError(opened.code, opened.code === 'link_expired' ? 'this link has expired' : 'this link is not valid');
285
+ throw error;
286
+ }
287
+ const [row] = await db.select().from(captures).where(eq(captures.fileKey, opened.key));
288
+ if (!row || row.photoDeletedAt) throw notFound('photo');
289
+ let stored;
290
+ try {
291
+ stored = await storage.get(row.fileKey);
292
+ } catch (error) {
293
+ const failure = error instanceof FlowError ? error : new FlowError('storage_unavailable', 'the file store could not be reached');
294
+ failure.captureId = row.id;
295
+ throw failure;
296
+ }
297
+ if (!stored) throw notFound('photo');
298
+ const sha = createHash('sha256').update(stored.body).digest('hex');
299
+ if (sha !== row.fileSha256) {
300
+ const error = new FlowError('hash_mismatch', 'the photo did not match its record');
301
+ error.captureId = row.id;
302
+ throw error;
303
+ }
304
+ return { body: stored.body, contentType: row.fileType, captureId: row.id };
305
+ }
306
+
307
+ // Deletes photos that are still here after a decision, and deletes captures
308
+ // that nobody decided and that are older than the retention window. A delete
309
+ // that fails leaves the row, so the next pass tries again. Two passes at once
310
+ // are safe: deleting a missing object and deleting a row that is already gone
311
+ // both do nothing.
312
+ export async function sweepCaptures(db, storage, { now = new Date(), retentionMs = CAPTURE_RETENTION_MS, limit = 100 } = {}) {
313
+ const cutoff = new Date(now.getTime() - retentionMs);
314
+ const open = { id: captures.id, organisationId: captures.organisationId, fileKey: captures.fileKey };
315
+ const stale = await db.select(open).from(captures).where(and(
316
+ isNull(captures.decision),
317
+ isNull(captures.photoDeletedAt),
318
+ lt(captures.createdAt, cutoff),
319
+ )).limit(limit);
320
+ const failed = [];
321
+ let removed = 0;
322
+ for (const row of stale) {
323
+ try {
324
+ await storage.delete(row.fileKey);
325
+ await db.delete(captures).where(and(
326
+ eq(captures.organisationId, row.organisationId),
327
+ eq(captures.id, row.id),
328
+ isNull(captures.decision),
329
+ ));
330
+ removed += 1;
331
+ } catch {
332
+ failed.push(row.id);
333
+ }
334
+ }
335
+ const stuck = await db.select(open).from(captures).where(and(
336
+ isNotNull(captures.decision),
337
+ isNull(captures.photoDeletedAt),
338
+ )).limit(limit);
339
+ let photos = 0;
340
+ for (const row of stuck) {
341
+ try {
342
+ await storage.delete(row.fileKey);
343
+ await db.update(captures).set({ photoDeletedAt: sql`now()`, updatedAt: sql`now()` }).where(and(
344
+ eq(captures.organisationId, row.organisationId),
345
+ eq(captures.id, row.id),
346
+ isNull(captures.photoDeletedAt),
347
+ ));
348
+ photos += 1;
349
+ } catch {
350
+ failed.push(row.id);
351
+ }
352
+ }
353
+ return { removed, photos, failed };
354
+ }
355
+
356
+ export function createCaptureRetentionWorker({
357
+ db, storage, log = console, retentionMs = CAPTURE_RETENTION_MS, everyMs = 60 * 60 * 1000, firstDelayMs = 60_000,
358
+ timers = globalThis,
359
+ }) {
360
+ let timer = null;
361
+ let stopped = true;
362
+ let running = null;
363
+
364
+ async function tick() {
365
+ try {
366
+ const result = await sweepCaptures(db, storage, { retentionMs });
367
+ for (const id of result.failed) log.error?.(`[captures] ${id} storage_unavailable`);
368
+ } catch {
369
+ log.error?.('[captures] - sweep_failed');
370
+ }
371
+ }
372
+
373
+ function schedule(delay) {
374
+ if (stopped) return;
375
+ timer = timers.setTimeout(async () => {
376
+ timer = null;
377
+ running = tick();
378
+ await running;
379
+ running = null;
380
+ schedule(everyMs);
381
+ }, delay);
382
+ timer?.unref?.();
383
+ }
384
+
385
+ return {
386
+ start() {
387
+ stopped = false;
388
+ schedule(firstDelayMs);
389
+ },
390
+ async stop() {
391
+ stopped = true;
392
+ if (timer) timers.clearTimeout(timer);
393
+ timer = null;
394
+ await running;
395
+ },
396
+ };
397
+ }
@@ -12,7 +12,10 @@ import { FlowError, notFound } from './errors.js';
12
12
  // Then wait: the job waits for a person; skip: rules never draft it;
13
13
  // pending: Flow drafts it, and the draft is Pending with the rule's
14
14
  // note, which says what to fix (for example a Reference with no
15
- // real practitioner).
15
+ // real practitioner); non_chargeable: Flow drafts it and keeps it on
16
+ // the Non-chargeable tab. The work is done but never billed (Xero's
17
+ // word for such work), so the draft is never sent. Its value stays
18
+ // in Flow, for the lab's output.
16
19
  //
17
20
  // The rules read only Flow's own records, so they work with or without an
18
21
  // accounting integration. The earlier fixed settings (old job limit, required
@@ -70,7 +73,7 @@ export const TESTS = Object.freeze({
70
73
  share: ['is', 'is_not'],
71
74
  });
72
75
 
73
- export const ACTIONS = Object.freeze(['wait', 'skip', 'pending']);
76
+ export const ACTIONS = Object.freeze(['wait', 'skip', 'pending', 'non_chargeable']);
74
77
  // Tests that take no value.
75
78
  const NO_VALUE = new Set(['is_empty', 'is_not_empty', 'has_number', 'number_in_words', 'one_word']);
76
79
 
@@ -187,7 +190,7 @@ export function ruleReasons(rules, job, { customer = {}, now = new Date() } = {}
187
190
  const context = { customer, now };
188
191
  const reasons = [];
189
192
  for (const rule of rules) {
190
- if (!rule.enabled || rule.trigger !== 'job_ready' || rule.action.kind === 'pending') continue;
193
+ if (!rule.enabled || rule.trigger !== 'job_ready' || ['pending', 'non_chargeable'].includes(rule.action.kind)) continue;
191
194
  if (!rule.conditions.every((c) => matches(c, job, context))) continue;
192
195
  reasons.push(rule.action.kind === 'skip' ? `rule "${rule.name}": not drafted by rule` : `rule "${rule.name}": waits for a person`);
193
196
  }
@@ -225,6 +228,17 @@ export function ruleSkip(rules, job, { customer = {}, now = new Date() } = {}) {
225
228
  }));
226
229
  }
227
230
 
231
+ // The enabled non-chargeable rules that match one draft's job (sending.js):
232
+ // the draft is on the Non-chargeable tab, never on Pending or Ready, and
233
+ // Send never sends it.
234
+ export function ruleNonChargeable(rules, job, { customer = {}, now = new Date() } = {}) {
235
+ const context = { customer, now };
236
+ return rules
237
+ .filter((r) => r.enabled && r.trigger === 'job_ready' && r.action.kind === 'non_chargeable')
238
+ .filter((r) => r.conditions.every((c) => matches(c, job, context)))
239
+ .map((r) => ({ code: 'non_chargeable', message: `Rule "${r.name}": non-chargeable. Flow keeps it and never sends it.`, short: r.name.slice(0, 40), rule: r.id ?? null }));
240
+ }
241
+
228
242
  // The starter rules made from the earlier fixed settings.
229
243
  export function starterRules(settings) {
230
244
  const rules = [
@@ -50,7 +50,7 @@ export async function describeObligations(db, organisationId, listed, { accounti
50
50
  const ready = new Set(views?.ready ?? []);
51
51
  const toCheck = new Set(views?.toCheck ?? []);
52
52
  const invoiceView = (id) => (!id || !views ? null
53
- : views.problems.has(id) ? 'problem' : ready.has(id) ? 'ready' : toCheck.has(id) ? 'to_check'
53
+ : views.problems.has(id) ? 'problem' : views.nonChargeable.has(id) ? 'non_chargeable' : ready.has(id) ? 'ready' : toCheck.has(id) ? 'to_check'
54
54
  : views.sending.has(id) ? 'sending' : null);
55
55
  // The invoicing rules a job with no invoice waits on, for every view a
56
56
  // draft can still come from.