@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
@@ -0,0 +1,158 @@
1
+ // The capture matcher (docs/DATA_MODEL.md, "Storage"). Flow code, not the
2
+ // model, picks the job a form is about. It scores each job on the Jobs board
3
+ // against the read or typed fields, in the style of the accounting link
4
+ // suggestions: a score, a rank and the signals behind it. A person always
5
+ // confirms the job before anything is applied.
6
+ //
7
+ // Points: case number 60 exact, 50 for a common misread (O for 0, l for 1),
8
+ // practice and dentist 20, due date within 3 days 10, work or teeth found in
9
+ // the job's lines 10.
10
+
11
+ export const MATCH_POINTS = Object.freeze({ caseNumber: 60, caseMisread: 50, practice: 12, dentist: 8, date: 10, work: 10 });
12
+ // A job at or above this, with no other job within SURE_GAP, is the one sure job.
13
+ export const SURE_SCORE = 60;
14
+ export const SURE_GAP = 20;
15
+ // A job below this is not shown.
16
+ export const SHOWN_SCORE = 20;
17
+ const SHOWN_MAX = 5;
18
+ const DATE_DAYS = 3;
19
+
20
+ // Fields that find the job (the work is its lines). They are not proposed as
21
+ // changes to a job that exists.
22
+ const IDENTITY = new Set(['case_number', 'practice', 'work']);
23
+ const MISREAD = { O: '0', Q: '0', D: '0', I: '1', L: '1', S: '5', B: '8', Z: '2', G: '6' };
24
+ const NAME_NOISE = new Set(['dr', 'dental', 'dentist', 'dentistry', 'clinic', 'surgery', 'practice', 'centre', 'center', 'pty', 'ltd', 'the', 'and', 'of']);
25
+
26
+ export const jobKey = (job) => `${job.sourceSystem}:${job.sourceJobRef}`;
27
+
28
+ const plain = (value) => String(value ?? '').trim();
29
+ const caseText = (value) => plain(value).toUpperCase().replace(/[^A-Z0-9]/g, '');
30
+ const misread = (value) => caseText(value).replace(/[OQDILSBZG]/g, (ch) => MISREAD[ch]);
31
+
32
+ function words(value) {
33
+ return plain(value).toLowerCase().replace(/[^a-z0-9 ]+/g, ' ').split(/\s+/)
34
+ .filter((word) => word.length > 1 && !NAME_NOISE.has(word));
35
+ }
36
+
37
+ // Edit distance, for close spelling of a name.
38
+ function distance(a, b) {
39
+ const row = Array.from({ length: b.length + 1 }, (_, i) => i);
40
+ for (let i = 1; i <= a.length; i += 1) {
41
+ let previous = row[0];
42
+ row[0] = i;
43
+ for (let j = 1; j <= b.length; j += 1) {
44
+ const kept = row[j];
45
+ row[j] = Math.min(row[j] + 1, row[j - 1] + 1, previous + (a[i - 1] === b[j - 1] ? 0 : 1));
46
+ previous = kept;
47
+ }
48
+ }
49
+ return row[b.length];
50
+ }
51
+
52
+ const closeWord = (a, b) => a === b || (Math.min(a.length, b.length) >= 4 && distance(a, b) <= Math.floor(Math.max(a.length, b.length) / 4));
53
+
54
+ // Half or more of the shorter name's words appear, by close spelling, in the other.
55
+ export function sameName(read, known) {
56
+ const a = words(read);
57
+ const b = words(known);
58
+ if (!a.length || !b.length) return false;
59
+ const [short, long] = a.length <= b.length ? [a, b] : [b, a];
60
+ const found = short.filter((word) => long.some((other) => closeWord(word, other))).length;
61
+ return found * 2 >= short.length;
62
+ }
63
+
64
+ // A date as a lab writes it: 2026-10-31, 31/10/2026, 31/10/26 or 31/10 (day
65
+ // first, as in Australia). A date with no year takes the year nearest `now`.
66
+ export function readDate(value, now = new Date()) {
67
+ const text = plain(value);
68
+ let match = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(text);
69
+ if (match) return utc(+match[1], +match[2], +match[3]);
70
+ match = /^(\d{1,2})[/.-](\d{1,2})(?:[/.-](\d{2}|\d{4}))?$/.exec(text);
71
+ if (!match) return null;
72
+ const [, day, month, year] = match;
73
+ if (year) return utc(year.length === 2 ? 2000 + +year : +year, +month, +day);
74
+ const near = [-1, 0, 1].map((step) => utc(now.getUTCFullYear() + step, +month, +day)).filter(Boolean);
75
+ return near.sort((a, b) => Math.abs(a - now) - Math.abs(b - now))[0] ?? null;
76
+ }
77
+
78
+ function utc(year, month, day) {
79
+ const date = new Date(Date.UTC(year, month - 1, day));
80
+ return date.getUTCMonth() === month - 1 && date.getUTCDate() === day ? date : null;
81
+ }
82
+
83
+ function teethOf(value) {
84
+ return plain(value).match(/\b[1-8][1-8]\b/g) ?? [];
85
+ }
86
+
87
+ function workFound(fields, job) {
88
+ const lines = (job.lines ?? []).map((line) => plain(line.description).toLowerCase()).join(' ');
89
+ if (!lines) return false;
90
+ const work = words(fields.work).filter((word) => word.length >= 4);
91
+ if (work.some((word) => lines.includes(word))) return true;
92
+ return teethOf(fields.teeth).some((tooth) => new RegExp(`\\b${tooth}\\b`).test(lines));
93
+ }
94
+
95
+ function score(fields, job, now) {
96
+ const signals = [];
97
+ const readCase = caseText(fields.case_number);
98
+ const jobCase = caseText(job.caseRef);
99
+ if (readCase && jobCase) {
100
+ if (readCase === jobCase) signals.push({ signal: 'case_number', points: MATCH_POINTS.caseNumber });
101
+ else if (misread(readCase) === misread(jobCase)) signals.push({ signal: 'case_number_misread', points: MATCH_POINTS.caseMisread });
102
+ }
103
+ if (sameName(fields.practice, job.clientName) || sameName(fields.practice, job.workClientName)) {
104
+ signals.push({ signal: 'practice', points: MATCH_POINTS.practice });
105
+ }
106
+ if (sameName(fields.dentist, job.practitioner)) signals.push({ signal: 'dentist', points: MATCH_POINTS.dentist });
107
+ const due = readDate(fields.due_date, now);
108
+ const jobDue = readDate(job.dueOn ?? job.jobDate, now);
109
+ if (due && jobDue && Math.abs(due - jobDue) <= DATE_DAYS * 86_400_000) signals.push({ signal: 'date', points: MATCH_POINTS.date });
110
+ if (workFound(fields, job)) signals.push({ signal: 'work', points: MATCH_POINTS.work });
111
+ return { score: signals.reduce((sum, s) => sum + s.points, 0), signals };
112
+ }
113
+
114
+ // `fields` is { key: value }. `jobs` is the Jobs board's jobs
115
+ // (server/core/job-board.js). Answers { outcome, jobs }: outcome `sure` (one
116
+ // job), `close` (a short pick list) or `none` (offer a new job).
117
+ export function matchJobs(fields, jobs, { now = new Date() } = {}) {
118
+ const ranked = (jobs ?? [])
119
+ .map((job) => ({ job, ...score(fields, job, now) }))
120
+ .filter((entry) => entry.score >= SHOWN_SCORE)
121
+ .sort((a, b) => b.score - a.score || plain(a.job.sourceJobRef).localeCompare(plain(b.job.sourceJobRef)))
122
+ .slice(0, SHOWN_MAX);
123
+ const [top, next] = ranked;
124
+ const outcome = !top ? 'none'
125
+ : top.score >= SURE_SCORE && (!next || top.score - next.score >= SURE_GAP) ? 'sure' : 'close';
126
+ return {
127
+ outcome,
128
+ jobs: ranked.map(({ job, score: points, signals }) => ({
129
+ jobId: jobKey(job), caseRef: job.caseRef ?? null, clientName: job.clientName ?? null, score: points, signals,
130
+ })),
131
+ };
132
+ }
133
+
134
+ // What the job holds now for a read field, where the board shows it.
135
+ function current(job, key) {
136
+ if (!job) return '';
137
+ if (key === 'dentist') return plain(job.practitioner);
138
+ if (key === 'due_date') return plain(job.dueOn);
139
+ return '';
140
+ }
141
+
142
+ // The proposed change from the fields and the match. One sure job: update it
143
+ // with each field that differs. Close jobs: update, and the person picks the
144
+ // job. No job: create one from the fields. A field the reader was not sure
145
+ // of keeps its sure score, so the screen can mark it.
146
+ export function proposeChange(read, match, jobs) {
147
+ const sure = match.outcome === 'sure' ? match.jobs[0] : null;
148
+ const job = sure ? (jobs ?? []).find((entry) => jobKey(entry) === sure.jobId) ?? null : null;
149
+ const changes = read
150
+ .filter((field) => plain(field.value) && (match.outcome === 'none' || !IDENTITY.has(field.key)))
151
+ .map((field) => ({ field: field.key, from: current(job, field.key), to: plain(field.value), sure: field.sure }))
152
+ .filter((change) => change.from !== change.to);
153
+ return {
154
+ action: match.outcome === 'none' ? 'create' : 'update',
155
+ jobId: sure?.jobId ?? null,
156
+ changes,
157
+ };
158
+ }
@@ -0,0 +1,501 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { and, desc, eq, gt, isNotNull, isNull, lt, or, sql } from 'drizzle-orm';
3
+ import { CAPTURE_DECISIONS, captures } from '../db/schema.js';
4
+ import { FlowError, notFound } from './errors.js';
5
+ import { matchJobs, proposeChange, readDate } from './capture-match.js';
6
+ import { listFields, setJobFieldValue } from './custom-fields.js';
7
+
8
+ // Shown when the module is off or the keys are missing. Type it still works.
9
+ const READER_OFF = 'reader turned off';
10
+
11
+ // A capture is one photo of a handwritten lab form (docs/DATA_MODEL.md,
12
+ // "Storage"). This does not change a job. The reader, when it is on, fills
13
+ // `read` in the background (server/core/reader.js). When it is off, or the
14
+ // keys are missing, the capture is `problem` ("reader turned off") and a
15
+ // person can type the proposal. A ready capture is matched to a job on the
16
+ // Jobs board (server/core/capture-match.js), and the match proposes the
17
+ // change. Apply or Discard records that decision and deletes the photo. A capture nobody decides is deleted after 7 days,
18
+ // photo included.
19
+ //
20
+ // Nothing here writes the photo, the typed fields or a patient name to a log.
21
+ // Callers log a capture id and an error code only.
22
+
23
+ export const MAX_PHOTO_BYTES = 12 * 1024 * 1024;
24
+ export const CAPTURE_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
25
+ export const EMPTY_PROPOSAL = Object.freeze({ action: null, jobId: null, changes: [] });
26
+
27
+ const PHOTO_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'image/heif']);
28
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
29
+ const ACTIONS = new Set(['update', 'create', 'note']);
30
+ // A job on the Jobs board: its work system and its id there (capture-match.js jobKey).
31
+ const JOB_ID = /^[a-z][a-z0-9_-]{0,39}:[^\u0000-\u001f]{1,200}$/;
32
+ const FIELD_MAX = 60;
33
+ const VALUE_MAX = 2000;
34
+ const CHANGES_MAX = 40;
35
+
36
+ const invalid = (message) => new FlowError('invalid', message);
37
+
38
+ export function readPhoto(bytes, contentType) {
39
+ // A request body can arrive as a string or a list. A photo is a buffer.
40
+ // The type check comes before any read of the bytes.
41
+ if (typeof bytes === 'string' || Array.isArray(bytes) || !Buffer.isBuffer(bytes)) throw invalid('photo is required');
42
+ const type = String(contentType ?? '').split(';')[0].trim().toLowerCase();
43
+ const fileType = type === 'image/jpg' ? 'image/jpeg' : type;
44
+ if (!PHOTO_TYPES.has(fileType)) throw new FlowError('unsupported_media', 'photo must be a jpeg, png, webp or heic');
45
+ if (bytes.length === 0) throw invalid('photo is required');
46
+ if (bytes.length > MAX_PHOTO_BYTES) throw new FlowError('photo_too_large', 'photo is too large');
47
+ if (!looksLikePhoto(fileType, bytes)) throw new FlowError('not_a_photo', 'photo could not be read');
48
+ return { fileType, fileSize: bytes.length, fileSha256: createHash('sha256').update(bytes).digest('hex') };
49
+ }
50
+
51
+ function looksLikePhoto(type, bytes) {
52
+ if (type === 'image/jpeg') return bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
53
+ if (type === 'image/png') return bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
54
+ if (type === 'image/webp') return bytes.length >= 12 && bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP';
55
+ if (bytes.length < 12 || bytes.toString('ascii', 4, 8) !== 'ftyp') return false;
56
+ const brand = bytes.toString('ascii', 8, 12);
57
+ return ['heic', 'heix', 'hevc', 'hevx', 'heim', 'heis', 'heif', 'mif1', 'msf1'].includes(brand);
58
+ }
59
+
60
+ export function readIdempotencyKey(raw) {
61
+ const key = String(raw ?? '').trim();
62
+ if (!key || key.length > 200 || /[\u0000-\u001f\u007f]/.test(key)) throw invalid('idempotency key is required');
63
+ return key;
64
+ }
65
+
66
+ export function readSource(raw) {
67
+ const source = String(raw ?? 'api').trim().toLowerCase() || 'api';
68
+ if (!['phone', 'desk', 'api'].includes(source)) throw invalid('source must be phone, desk or api');
69
+ return source;
70
+ }
71
+
72
+ function readText(value, max) {
73
+ if (typeof value !== 'string') throw invalid('a change must be text');
74
+ const trimmed = value.trim();
75
+ if (trimmed.length > max) throw invalid('a change is too long');
76
+ return trimmed;
77
+ }
78
+
79
+ // What a person typed. The values are stored on the capture and returned to
80
+ // that person. They are not written to a log.
81
+ export function readProposal(input) {
82
+ if (input == null) return { ...EMPTY_PROPOSAL, changes: [] };
83
+ const action = input.action ?? null;
84
+ if (action !== null && !ACTIONS.has(action)) throw invalid('action must be update, create or note');
85
+ const jobId = input.jobId ?? null;
86
+ if (jobId !== null && !JOB_ID.test(String(jobId))) throw invalid('jobId must be a job id');
87
+ if (!Array.isArray(input.changes)) throw invalid('changes must be a list');
88
+ if (input.changes.length > CHANGES_MAX) throw invalid('too many changes');
89
+ const changes = input.changes.map((change) => ({
90
+ field: readText(change?.field, FIELD_MAX),
91
+ from: readText(change?.from ?? '', VALUE_MAX),
92
+ to: readText(change?.to ?? '', VALUE_MAX),
93
+ ...(Number.isFinite(change?.sure) ? { sure: Math.min(1, Math.max(0, change.sure)) } : {}),
94
+ }));
95
+ if (changes.some((change) => !change.field)) throw invalid('a change needs a field');
96
+ if (changes.length > 0 && !action) throw invalid('action is required');
97
+ return { action, jobId, changes };
98
+ }
99
+
100
+ export function captureView(row, link = null) {
101
+ return {
102
+ id: row.id,
103
+ organisationId: row.organisationId,
104
+ state: row.state,
105
+ source: row.source,
106
+ fileType: row.fileType,
107
+ fileSize: row.fileSize,
108
+ fileSha256: row.fileSha256,
109
+ proposal: row.proposal ?? { ...EMPTY_PROPOSAL, changes: [] },
110
+ read: row.read ?? null,
111
+ match: row.match ?? null,
112
+ problem: row.problem,
113
+ decision: row.decision,
114
+ decidedAt: row.decidedAt,
115
+ photoDeletedAt: row.photoDeletedAt,
116
+ createdAt: row.createdAt,
117
+ link,
118
+ };
119
+ }
120
+
121
+ function linkFor(storage, row, baseUrl) {
122
+ if (!row || row.photoDeletedAt) return null;
123
+ return storage.signedLink({ key: row.fileKey, baseUrl });
124
+ }
125
+
126
+ async function byId(db, organisationId, id) {
127
+ if (!UUID.test(String(id ?? ''))) throw notFound('capture');
128
+ const [row] = await db.select().from(captures).where(and(
129
+ eq(captures.organisationId, organisationId),
130
+ eq(captures.id, id),
131
+ ));
132
+ if (!row) throw notFound('capture');
133
+ return row;
134
+ }
135
+
136
+ async function byIdempotency(db, organisationId, idempotencyKey) {
137
+ const [row] = await db.select().from(captures).where(and(
138
+ eq(captures.organisationId, organisationId),
139
+ eq(captures.idempotencyKey, idempotencyKey),
140
+ ));
141
+ return row ?? null;
142
+ }
143
+
144
+ // The first body stored for an idempotency key wins. A repeat upload, even
145
+ // with different bytes, returns that capture and stores nothing else. If the
146
+ // process stops after the bytes are stored and before the row is inserted,
147
+ // the object is orphaned: its key is a random id and it is not a capture.
148
+ export async function uploadCapture(db, storage, organisationId, {
149
+ bytes, contentType, idempotencyKey, source = 'api', personId, baseUrl, reader = null,
150
+ }) {
151
+ const photo = readPhoto(bytes, contentType);
152
+ const key = readIdempotencyKey(idempotencyKey);
153
+ const from = readSource(source);
154
+ const existing = await byIdempotency(db, organisationId, key);
155
+ if (existing) {
156
+ // The read is queued after the row is inserted. A repeat of the same
157
+ // key starts it if that queue step did not finish. It does not read a
158
+ // capture that already has a result.
159
+ if (existing.state === 'reading' && reader?.on) {
160
+ const settled = await reader.schedule(existing);
161
+ return { capture: settled ?? existing, created: false, link: linkFor(storage, settled ?? existing, baseUrl) };
162
+ }
163
+ return { capture: existing, created: false, link: linkFor(storage, existing, baseUrl) };
164
+ }
165
+
166
+ const id = randomUUID();
167
+ const fileKey = `captures/${String(organisationId).toLowerCase()}/${id}`;
168
+ try {
169
+ await storage.put({ key: fileKey, body: bytes, contentType: photo.fileType });
170
+ } catch (error) {
171
+ if (error instanceof FlowError) throw error;
172
+ throw new FlowError('storage_unavailable', 'the photo could not be stored');
173
+ }
174
+ try {
175
+ const reading = Boolean(reader?.on);
176
+ const [row] = await db.insert(captures).values({
177
+ id,
178
+ organisationId,
179
+ state: reading ? 'reading' : 'problem',
180
+ source: from,
181
+ fileKey,
182
+ fileType: photo.fileType,
183
+ fileSize: photo.fileSize,
184
+ fileSha256: photo.fileSha256,
185
+ idempotencyKey: key,
186
+ proposal: { ...EMPTY_PROPOSAL, changes: [] },
187
+ problem: reading ? null : READER_OFF,
188
+ createdBy: personId ?? null,
189
+ }).onConflictDoNothing({ target: [captures.organisationId, captures.idempotencyKey] }).returning();
190
+ if (row) {
191
+ // With background jobs off, schedule reads before it returns, so the
192
+ // response is the finished capture. With jobs on, the row stays `reading`.
193
+ const settled = reading ? await reader.schedule(row) : row;
194
+ return { capture: settled ?? row, created: true, link: linkFor(storage, settled ?? row, baseUrl) };
195
+ }
196
+ } catch (error) {
197
+ await storage.delete(fileKey).catch(() => {});
198
+ if (error instanceof FlowError) throw error;
199
+ throw new FlowError('storage_unavailable', 'the photo could not be stored');
200
+ }
201
+ // Another request stored this key first. Drop the bytes we just wrote.
202
+ // Their key is this attempt's id, not the winner's.
203
+ await storage.delete(fileKey).catch(() => {});
204
+ const winner = await byIdempotency(db, organisationId, key);
205
+ if (!winner) throw new FlowError('conflict', 'this upload was already stored');
206
+ return { capture: winner, created: false, link: linkFor(storage, winner, baseUrl) };
207
+ }
208
+
209
+ export async function readCapture(db, storage, organisationId, id, baseUrl) {
210
+ const row = await byId(db, organisationId, id);
211
+ return { capture: row, link: linkFor(storage, row, baseUrl) };
212
+ }
213
+
214
+ export async function typeProposal(db, storage, organisationId, id, input, { baseUrl } = {}) {
215
+ const proposal = readProposal(input);
216
+ await byId(db, organisationId, id);
217
+ const [row] = await db.update(captures).set({
218
+ state: 'ready',
219
+ problem: null,
220
+ proposal,
221
+ updatedAt: sql`now()`,
222
+ }).where(and(
223
+ eq(captures.organisationId, organisationId),
224
+ eq(captures.id, id),
225
+ sql`${captures.state} in ('ready', 'problem')`,
226
+ )).returning();
227
+ if (!row) throw new FlowError('conflict', 'this capture is not ready');
228
+ return { capture: row, link: linkFor(storage, row, baseUrl) };
229
+ }
230
+
231
+ // What a person typed instead of a read: { key: value }, keys as the reader's.
232
+ function readTypedFields(input) {
233
+ if (input == null || typeof input !== 'object' || Array.isArray(input)) throw invalid('fields must be an object');
234
+ const entries = Object.entries(input);
235
+ if (entries.length > CHANGES_MAX) throw invalid('too many fields');
236
+ return entries.map(([key, value]) => ({ key: readText(key, FIELD_MAX), value: readText(value ?? '', VALUE_MAX), seen: '', sure: 1 }));
237
+ }
238
+
239
+ const isEmpty = (proposal) => !proposal?.action && !(proposal?.changes?.length);
240
+
241
+ // Scores the Jobs board against the read (or typed) fields, stores the match
242
+ // and, when nobody has typed a change yet, the proposed change. Typed fields
243
+ // always replace the proposal: the person asked for a new match. `jobs` is
244
+ // the board's jobs (server/core/job-board.js boardJobs).
245
+ export async function matchCapture(db, storage, organisationId, id, { jobs, fields = null, baseUrl, now } = {}) {
246
+ const current = await byId(db, organisationId, id);
247
+ if (current.state !== 'ready' && !(fields && current.state === 'problem')) throw new FlowError('conflict', 'this capture is not ready');
248
+ const read = fields ? readTypedFields(fields) : (current.read?.fields ?? []);
249
+ const values = Object.fromEntries(read.map((field) => [field.key, field.value]));
250
+ const match = matchJobs(values, jobs, { now });
251
+ const replace = Boolean(fields) || isEmpty(current.proposal);
252
+ const [row] = await db.update(captures).set({
253
+ state: 'ready',
254
+ problem: null,
255
+ match,
256
+ ...(replace ? { proposal: proposeChange(read, match, jobs) } : {}),
257
+ updatedAt: sql`now()`,
258
+ }).where(and(
259
+ eq(captures.organisationId, organisationId),
260
+ eq(captures.id, id),
261
+ eq(captures.state, current.state),
262
+ )).returning();
263
+ if (!row) throw new FlowError('conflict', 'this capture changed; open it again');
264
+ return { capture: row, link: linkFor(storage, row, baseUrl) };
265
+ }
266
+
267
+ // The capture tray: every capture still open, and the ones decided in the
268
+ // last day, newest first. No signed link: the result sheet asks for one.
269
+ export async function listCaptures(db, organisationId, { now = new Date(), limit = 100 } = {}) {
270
+ const since = new Date(now.getTime() - 24 * 60 * 60 * 1000);
271
+ return db.select().from(captures).where(and(
272
+ eq(captures.organisationId, organisationId),
273
+ or(isNull(captures.decision), gt(captures.decidedAt, since)),
274
+ )).orderBy(desc(captures.createdAt)).limit(limit);
275
+ }
276
+
277
+ // A custom field's value from the text on the form. Undefined: Flow cannot
278
+ // set this field from text (a person field), so it is left as it is.
279
+ function fieldValue(field, text) {
280
+ const bad = () => invalid(`${field.name}: "${text}" does not fit this field`);
281
+ switch (field.type) {
282
+ case 'number': {
283
+ const number = Number(text.replace(/,/g, ''));
284
+ if (!Number.isFinite(number)) throw bad();
285
+ return number;
286
+ }
287
+ case 'date': {
288
+ const date = readDate(text);
289
+ if (!date) throw bad();
290
+ return date.toISOString().slice(0, 10);
291
+ }
292
+ case 'dropdown': {
293
+ const option = field.options.find((o) => !o.archived && o.name.toLowerCase() === text.toLowerCase());
294
+ if (!option) throw bad();
295
+ return option.id;
296
+ }
297
+ case 'checkbox':
298
+ if (/^(yes|y|true|x|✓|1)$/i.test(text)) return true;
299
+ if (/^(no|n|false|0)$/i.test(text)) return false;
300
+ throw bad();
301
+ case 'person':
302
+ return undefined;
303
+ default:
304
+ return text;
305
+ }
306
+ }
307
+
308
+ // Apply writes what Flow owns: the job's custom fields, each with its own
309
+ // event (who, when, from and to). A field the work system owns (due date,
310
+ // shade) stays there; the result sheet links to the job in that system.
311
+ // Every value is checked before the first write, so a bad one writes none.
312
+ async function applyToJob(db, organisationId, proposal, personId) {
313
+ if (proposal?.action !== 'update' || !proposal.jobId) return;
314
+ const at = proposal.jobId.indexOf(':');
315
+ const job = { sourceSystem: proposal.jobId.slice(0, at), sourceJobRef: proposal.jobId.slice(at + 1) };
316
+ const fields = new Map((await listFields(db, organisationId)).map((field) => [`custom_${field.id}`, field]));
317
+ const writes = proposal.changes
318
+ .filter((change) => fields.has(change.field))
319
+ .map((change) => ({ field: fields.get(change.field), value: fieldValue(fields.get(change.field), change.to) }))
320
+ .filter((write) => write.value !== undefined);
321
+ for (const { field, value } of writes) {
322
+ await setJobFieldValue(db, organisationId, { ...job, fieldId: field.id, value }, { personId });
323
+ }
324
+ }
325
+
326
+ async function deletePhoto(db, storage, row) {
327
+ if (row.photoDeletedAt) return row;
328
+ try {
329
+ await storage.delete(row.fileKey);
330
+ } catch (error) {
331
+ const failure = error instanceof FlowError ? error : new FlowError('storage_unavailable', 'the photo could not be deleted');
332
+ failure.captureId = row.id;
333
+ if (!failure.code) failure.code = 'storage_unavailable';
334
+ throw failure;
335
+ }
336
+ const [next] = await db.update(captures).set({
337
+ photoDeletedAt: sql`now()`,
338
+ updatedAt: sql`now()`,
339
+ }).where(and(
340
+ eq(captures.organisationId, row.organisationId),
341
+ eq(captures.id, row.id),
342
+ isNull(captures.photoDeletedAt),
343
+ )).returning();
344
+ return next ?? row;
345
+ }
346
+
347
+ // Records the person's decision and deletes the photo. Apply first writes
348
+ // the job's custom fields (applyToJob). The same decision again writes the
349
+ // same values, which changes nothing, and deletes the photo if that was
350
+ // still waiting.
351
+ export async function decideCapture(db, storage, organisationId, id, decision, { personId, baseUrl } = {}) {
352
+ if (!CAPTURE_DECISIONS.includes(decision)) throw invalid('decision must be applied or discarded');
353
+ const current = await byId(db, organisationId, id);
354
+ if (current.state === 'done') {
355
+ if (current.decision !== decision) throw new FlowError('conflict', 'this capture already has a decision');
356
+ const row = await deletePhoto(db, storage, current);
357
+ return { capture: row, link: null };
358
+ }
359
+ if (current.state !== 'ready') throw new FlowError('conflict', 'this capture is not ready');
360
+ if (decision === 'applied') await applyToJob(db, organisationId, current.proposal, personId ?? null);
361
+ const [row] = await db.update(captures).set({
362
+ state: 'done',
363
+ decision,
364
+ decidedBy: personId ?? null,
365
+ decidedAt: sql`now()`,
366
+ updatedAt: sql`now()`,
367
+ }).where(and(
368
+ eq(captures.organisationId, organisationId),
369
+ eq(captures.id, id),
370
+ eq(captures.state, 'ready'),
371
+ )).returning();
372
+ if (!row) {
373
+ const again = await byId(db, organisationId, id);
374
+ if (again.state === 'done' && again.decision === decision) {
375
+ return { capture: await deletePhoto(db, storage, again), link: null };
376
+ }
377
+ throw new FlowError('conflict', again.state === 'done' ? 'this capture already has a decision' : 'this capture is not ready');
378
+ }
379
+ const deleted = await deletePhoto(db, storage, row);
380
+ return { capture: deleted, link: null };
381
+ }
382
+
383
+ // Serves a signed Flow link. The checksum is checked before any byte is
384
+ // returned. An S3 link is not served here; S3 honours that URL itself.
385
+ export async function readCapturePhoto(db, storage, token) {
386
+ const opened = storage.openLink(String(token ?? ''));
387
+ if (!opened.ok) {
388
+ const error = new FlowError(opened.code, opened.code === 'link_expired' ? 'this link has expired' : 'this link is not valid');
389
+ throw error;
390
+ }
391
+ const [row] = await db.select().from(captures).where(eq(captures.fileKey, opened.key));
392
+ if (!row || row.photoDeletedAt) throw notFound('photo');
393
+ let stored;
394
+ try {
395
+ stored = await storage.get(row.fileKey);
396
+ } catch (error) {
397
+ const failure = error instanceof FlowError ? error : new FlowError('storage_unavailable', 'the file store could not be reached');
398
+ failure.captureId = row.id;
399
+ throw failure;
400
+ }
401
+ if (!stored) throw notFound('photo');
402
+ const sha = createHash('sha256').update(stored.body).digest('hex');
403
+ if (sha !== row.fileSha256) {
404
+ const error = new FlowError('hash_mismatch', 'the photo did not match its record');
405
+ error.captureId = row.id;
406
+ throw error;
407
+ }
408
+ return { body: stored.body, contentType: row.fileType, captureId: row.id };
409
+ }
410
+
411
+ // Deletes photos that are still here after a decision, and deletes captures
412
+ // that nobody decided and that are older than the retention window. A delete
413
+ // that fails leaves the row, so the next pass tries again. Two passes at once
414
+ // are safe: deleting a missing object and deleting a row that is already gone
415
+ // both do nothing.
416
+ export async function sweepCaptures(db, storage, { now = new Date(), retentionMs = CAPTURE_RETENTION_MS, limit = 100 } = {}) {
417
+ const cutoff = new Date(now.getTime() - retentionMs);
418
+ const open = { id: captures.id, organisationId: captures.organisationId, fileKey: captures.fileKey };
419
+ const stale = await db.select(open).from(captures).where(and(
420
+ isNull(captures.decision),
421
+ isNull(captures.photoDeletedAt),
422
+ lt(captures.createdAt, cutoff),
423
+ )).limit(limit);
424
+ const failed = [];
425
+ let removed = 0;
426
+ for (const row of stale) {
427
+ try {
428
+ await storage.delete(row.fileKey);
429
+ await db.delete(captures).where(and(
430
+ eq(captures.organisationId, row.organisationId),
431
+ eq(captures.id, row.id),
432
+ isNull(captures.decision),
433
+ ));
434
+ removed += 1;
435
+ } catch {
436
+ failed.push(row.id);
437
+ }
438
+ }
439
+ const stuck = await db.select(open).from(captures).where(and(
440
+ isNotNull(captures.decision),
441
+ isNull(captures.photoDeletedAt),
442
+ )).limit(limit);
443
+ let photos = 0;
444
+ for (const row of stuck) {
445
+ try {
446
+ await storage.delete(row.fileKey);
447
+ await db.update(captures).set({ photoDeletedAt: sql`now()`, updatedAt: sql`now()` }).where(and(
448
+ eq(captures.organisationId, row.organisationId),
449
+ eq(captures.id, row.id),
450
+ isNull(captures.photoDeletedAt),
451
+ ));
452
+ photos += 1;
453
+ } catch {
454
+ failed.push(row.id);
455
+ }
456
+ }
457
+ return { removed, photos, failed };
458
+ }
459
+
460
+ export function createCaptureRetentionWorker({
461
+ db, storage, log = console, retentionMs = CAPTURE_RETENTION_MS, everyMs = 60 * 60 * 1000, firstDelayMs = 60_000,
462
+ timers = globalThis,
463
+ }) {
464
+ let timer = null;
465
+ let stopped = true;
466
+ let running = null;
467
+
468
+ async function tick() {
469
+ try {
470
+ const result = await sweepCaptures(db, storage, { retentionMs });
471
+ for (const id of result.failed) log.error?.(`[captures] ${id} storage_unavailable`);
472
+ } catch {
473
+ log.error?.('[captures] - sweep_failed');
474
+ }
475
+ }
476
+
477
+ function schedule(delay) {
478
+ if (stopped) return;
479
+ timer = timers.setTimeout(async () => {
480
+ timer = null;
481
+ running = tick();
482
+ await running;
483
+ running = null;
484
+ schedule(everyMs);
485
+ }, delay);
486
+ timer?.unref?.();
487
+ }
488
+
489
+ return {
490
+ start() {
491
+ stopped = false;
492
+ schedule(firstDelayMs);
493
+ },
494
+ async stop() {
495
+ stopped = true;
496
+ if (timer) timers.clearTimeout(timer);
497
+ timer = null;
498
+ await running;
499
+ },
500
+ };
501
+ }