@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
@@ -162,6 +162,20 @@
162
162
  "when": 1792733287865,
163
163
  "tag": "0022_contacts",
164
164
  "breakpoints": true
165
+ },
166
+ {
167
+ "idx": 23,
168
+ "version": "7",
169
+ "when": 1793110575758,
170
+ "tag": "0023_captures",
171
+ "breakpoints": true
172
+ },
173
+ {
174
+ "idx": 24,
175
+ "version": "7",
176
+ "when": 1793233782043,
177
+ "tag": "0024_rule_icon",
178
+ "breakpoints": true
165
179
  }
166
180
  ]
167
181
  }
@@ -694,6 +694,8 @@ export const invoiceRules = pgTable('invoice_rules', {
694
694
  id: id(),
695
695
  organisationId: organisationId().references(() => organisations.id),
696
696
  name: text('name').notNull(),
697
+ // A Lucide icon name for the rule's card; null shows the action's mark.
698
+ icon: text('icon'),
697
699
  position: integer('position').notNull(),
698
700
  enabled: boolean('enabled').notNull().default(true),
699
701
  trigger: text('trigger').notNull().default('job_ready'),
@@ -707,6 +709,7 @@ export const invoiceRules = pgTable('invoice_rules', {
707
709
  uniqueIndex('invoice_rules_starter_key').on(t.organisationId, t.starterKey),
708
710
  check('invoice_rules_trigger_valid', sql`${t.trigger} in ('job_ready')`),
709
711
  check('invoice_rules_name_valid', sql`length(${t.name}) between 1 and 120`),
712
+ check('invoice_rules_icon_valid', sql`${t.icon} is null or (length(${t.icon}) between 1 and 64 and ${t.icon} !~ '[^a-z0-9-]')`),
710
713
  ]);
711
714
 
712
715
  // Automations (server/core/automations.js): Flow's routines, each on or off,
@@ -858,3 +861,50 @@ export const jobFieldEvents = pgTable('job_field_events', {
858
861
  foreignKey({ name: 'job_field_events_field_same_org', columns: [t.organisationId, t.fieldId], foreignColumns: [jobFields.organisationId, jobFields.id] }).onDelete('cascade'),
859
862
  index('job_field_events_job').on(t.organisationId, t.sourceSystem, t.sourceJobRef, t.createdAt),
860
863
  ]);
864
+
865
+ // A photo of a handwritten lab form (server/core/captures.js). The bytes live
866
+ // in the object storage adapter; this row keeps the key, size, type and
867
+ // checksum. `read` is what the vision reader returned (server/core/reader.js):
868
+ // the fields, the model, the tokens and the cost. A new row is `reading`
869
+ // while that runs, or `problem` when the reader is off. A person may type
870
+ // the proposal either way. `done` means someone applied or discarded it.
871
+ // The photo bytes are deleted then, and a row with no decision is deleted
872
+ // after 7 days.
873
+ export const CAPTURE_STATES = ['reading', 'ready', 'done', 'problem'];
874
+ export const CAPTURE_SOURCES = ['phone', 'desk', 'api'];
875
+ export const CAPTURE_DECISIONS = ['applied', 'discarded'];
876
+ export const captures = pgTable('captures', {
877
+ id: id(),
878
+ organisationId: organisationId().references(() => organisations.id),
879
+ state: text('state').notNull(),
880
+ source: text('source').notNull(),
881
+ fileKey: text('file_key').notNull(),
882
+ fileType: text('file_type').notNull(),
883
+ fileSize: integer('file_size').notNull(),
884
+ fileSha256: text('file_sha256').notNull(),
885
+ idempotencyKey: text('idempotency_key').notNull(),
886
+ // Fields, model, token counts and cost in micros. Null until a read finishes.
887
+ read: jsonb('read'),
888
+ match: jsonb('match'),
889
+ proposal: jsonb('proposal').notNull().default(sql`'{"action":null,"jobId":null,"changes":[]}'::jsonb`),
890
+ problem: text('problem'),
891
+ decision: text('decision'),
892
+ decidedBy: uuid('decided_by').references(() => people.id, { onDelete: 'set null' }),
893
+ decidedAt: timestamp('decided_at', { withTimezone: true }),
894
+ photoDeletedAt: timestamp('photo_deleted_at', { withTimezone: true }),
895
+ createdBy: uuid('created_by').references(() => people.id, { onDelete: 'set null' }),
896
+ createdAt: createdAt(),
897
+ updatedAt: updatedAt(),
898
+ }, (t) => [
899
+ unique('captures_org_id').on(t.organisationId, t.id),
900
+ unique('captures_org_idempotency').on(t.organisationId, t.idempotencyKey),
901
+ unique('captures_file_key').on(t.fileKey),
902
+ index('captures_photo_open').on(t.createdAt).where(sql`photo_deleted_at is null`),
903
+ check('captures_state_valid', inList('state', CAPTURE_STATES)),
904
+ check('captures_source_valid', inList('source', CAPTURE_SOURCES)),
905
+ check('captures_decision_valid', sql`${t.decision} is null or ${t.decision} in ('applied', 'discarded')`),
906
+ check('captures_done_has_decision', sql`(${t.state} = 'done') = (${t.decision} is not null)`),
907
+ check('captures_file_size_valid', sql`${t.fileSize} >= 0`),
908
+ check('captures_sha256_valid', sql`${t.fileSha256} ~ '^[0-9a-f]{64}$'`),
909
+ check('captures_problem_valid', sql`${t.problem} is null or length(${t.problem}) between 1 and 200`),
910
+ ]);
package/server/index.js CHANGED
@@ -9,7 +9,12 @@ import { migrateDatabase, openDatabase } from './db/index.js';
9
9
  import {
10
10
  importEarlierAtStart, importEarlierLinksAtStart, importEarlierMappingsAtStart, resolveImportAtStart,
11
11
  } from './import/at-start.js';
12
+ import { createAiPort, resolveReader } from './core/ai.js';
13
+ import { createBedrockClient } from './core/ai-bedrock.js';
14
+ import { createCaptureRetentionWorker } from './core/captures.js';
12
15
  import { createMonitoredLog } from './core/monitoring.js';
16
+ import { createCaptureReader } from './core/reader.js';
17
+ import { resolveObjectStorage } from './core/storage.js';
13
18
  import { createTracing, traceQueries } from './core/tracing.js';
14
19
  import { createPipelineWorker } from './core/pipeline.js';
15
20
  import { createReconcileWorker } from './core/reconcile.js';
@@ -117,6 +122,43 @@ if (jobsOn) createReconcileWorker({ db: database.db, integrations, log }).start(
117
122
  // with Send all ready, to the accounting integration that is on.
118
123
  if (jobsOn) createSendWorker({ db: database.db, integrations, log }).start();
119
124
 
125
+ // Capture photos (server/core/storage.js). A bad storage setting stops boot.
126
+ // On Replit, and where the local folder cannot be created, the default is
127
+ // off. The retention pass
128
+ // only deletes Flow's own files, so it still runs where background jobs are
129
+ // off: a second copy deleting the same key does nothing.
130
+ let storage = null;
131
+ try {
132
+ const resolved = await resolveObjectStorage({ env: process.env, secrets });
133
+ storage = resolved.storage;
134
+ if (storage) console.log(`[storage] ${resolved.mode}`);
135
+ else console.log(`[storage] captures off (${resolved.reason})`);
136
+ } catch (error) {
137
+ console.error(`[storage] ${error.message}`);
138
+ process.exit(1);
139
+ }
140
+ if (storage) createCaptureRetentionWorker({ db: database.db, storage, log }).start();
141
+
142
+ // The vision reader (server/core/reader.js). A bad region or a model id that
143
+ // is not an Australia profile stops boot. Off, or on without keys, stores
144
+ // the photo and leaves the capture for a person to type.
145
+ let reader = null;
146
+ try {
147
+ const resolved = resolveReader({ env: process.env });
148
+ if (!resolved.on) console.log(`[reader] off (${resolved.reason})`);
149
+ else if (!storage) console.log('[reader] captures off (no file store)');
150
+ else {
151
+ const port = createAiPort(createBedrockClient(resolved));
152
+ reader = createCaptureReader({ db: database.db, storage, port, settings: resolved, log, jobsOn });
153
+ if (jobsOn) reader.worker.start();
154
+ else console.log('[reader] background jobs off: each capture is read in the upload');
155
+ console.log(`[reader] on (${resolved.region})`);
156
+ }
157
+ } catch (error) {
158
+ console.error(`[reader] ${error.message}`);
159
+ process.exit(1);
160
+ }
161
+
120
162
  let auth = null;
121
163
  if (settings.mode === 'clerk') {
122
164
  const { keys } = settings;
@@ -158,7 +200,11 @@ const trustProxy = process.env.REPL_ID || process.env.REPLIT_DEPLOYMENT ? 1 : fa
158
200
 
159
201
  const updates = createUpdateChecker();
160
202
 
161
- createApp({ database, schemaJournals: expectedSchema({ adapters: shippedAdapters }).journals, auth, trustProxy, updates, integrations, secrets, defaultFamily, log, tracing }).listen(port, () => {
203
+ createApp({
204
+ database, schemaJournals: expectedSchema({ adapters: shippedAdapters }).journals, auth, trustProxy, updates, integrations, secrets, defaultFamily, log, tracing, storage,
205
+ publicOrigin: process.env.FLOW_PUBLIC_ORIGIN,
206
+ reader: reader ? { on: true, schedule: reader.schedule } : null,
207
+ }).listen(port, () => {
162
208
  console.log(`[flow] listening on ${port}; design system ${defaultFamily} (FLOW_THEME)`);
163
209
  });
164
210
 
@@ -1,4 +1,4 @@
1
- import { and, eq, inArray } from 'drizzle-orm';
1
+ import { eq, inArray } from 'drizzle-orm';
2
2
  import { FlowError } from '../../../core/errors.js';
3
3
  import { mapOrder, parseSeazonaDate, text } from '../map.js';
4
4
  import { seazonaHistoryBatchJobs, seazonaLaneState, seazonaOrders } from '../schema.js';
@@ -11,8 +11,9 @@ import { createApiBatch, finishApiBatch, stageApiJobs } from './index.js';
11
11
  // detail. It makes at most
12
12
  // `historyCallsPerRun` calls a run, through the same rate governor and call
13
13
  // guards as the live lanes, and keeps its place in the lane's cursor, so it
14
- // resumes after a restart. Orders the live copy already holds are not read
15
- // again. Each order becomes one job in an API batch: reconciled as it is
14
+ // resumes after a restart. Orders the live copy already holds, and orders
15
+ // any earlier backfill read, are not read again, so a read started again over
16
+ // the same days costs only the list calls. Each order becomes one job in an API batch: reconciled as it is
16
17
  // read, written to Flow's history when it is new, and kept for a person when
17
18
  // it is not. The batch rows are the record of what the API confirmed.
18
19
 
@@ -65,7 +66,7 @@ export async function startBackfill(db, { from, through = null, createdBy, setti
65
66
  throw Object.assign(new FlowError('conflict', 'A history backfill is already running'), { status: 409 });
66
67
  }
67
68
  const batch = await createApiBatch(db, { createdBy, from, through: last, at });
68
- const cursor = { batchId: batch.id, from, through: last, nextDay: from, page: 1, pending: [], done: false, days: 0, read: 0 };
69
+ const cursor = { batchId: batch.id, from, through: last, nextDay: from, page: 1, pending: [], done: false, days: 0, read: 0, startedAt: at.toISOString() };
69
70
  await db.insert(seazonaLaneState).values({ lane: LANE, status: 'idle', cursor, nextRunAt: at })
70
71
  .onConflictDoUpdate({ target: seazonaLaneState.lane, set: { status: 'idle', cursor, nextRunAt: at, failureCount: 0, lastError: null, heldReason: null, updatedAt: at } });
71
72
  return describeBackfill(cursor);
@@ -89,19 +90,47 @@ export function describeBackfill(cursor) {
89
90
  batchId: cursor.batchId,
90
91
  from: cursor.from,
91
92
  through: cursor.through,
93
+ startedAt: cursor.startedAt ?? null,
92
94
  nextDay: cursor.done ? null : cursor.nextDay,
95
+ // The day whose orders it reads now: the last day listed while orders
96
+ // from it wait, else the next day to list.
97
+ readingDay: cursor.done ? null : (cursor.pending?.length && cursor.listDay) || cursor.nextDay,
93
98
  daysDone: cursor.days ?? 0,
94
99
  reachedDay: cursor.reached ?? null,
95
100
  ordersRead: cursor.read ?? 0,
96
101
  waitingToRead: cursor.pending?.length ?? 0,
97
102
  running: !cursor.done,
98
103
  stopped: Boolean(cursor.stopped),
104
+ // Where a stopped read goes on: the day it listed last while orders from
105
+ // that list were still to read, else the next day.
106
+ resumeFrom: !cursor.stopped ? null
107
+ : cursor.pending?.length && cursor.listDay ? cursor.listDay
108
+ : cursor.nextDay <= cursor.through ? cursor.nextDay : null,
99
109
  };
100
110
  }
101
111
 
112
+ // A read that failed this many runs in a row shows as failed, not waiting.
113
+ const FAILED_AFTER = 3;
114
+
115
+ // The card's phase. A held or failed lane waits for its retry at nextRunAt;
116
+ // a lane Seazona halted, or one that failed FAILED_AFTER times, has failed.
117
+ function phaseOf(backfill, state) {
118
+ if (!backfill.running) return backfill.stopped ? 'stopped' : 'done';
119
+ if (state.status === 'stopped' || (state.status === 'failed' && state.failureCount >= FAILED_AFTER)) return 'failed';
120
+ return ['held', 'failed'].includes(state.status) ? 'waiting' : 'running';
121
+ }
122
+
102
123
  export async function readBackfill(db) {
103
124
  const [state] = await db.select().from(seazonaLaneState).where(eq(seazonaLaneState.lane, LANE));
104
- return describeBackfill(state?.cursor);
125
+ const backfill = describeBackfill(state?.cursor);
126
+ if (!backfill) return null;
127
+ const phase = phaseOf(backfill, state);
128
+ return {
129
+ ...backfill,
130
+ phase,
131
+ cause: ['waiting', 'failed'].includes(phase) ? state.lastError : null,
132
+ nextRunAt: backfill.running ? state.nextRunAt : null,
133
+ };
105
134
  }
106
135
 
107
136
  // One run of the `history` lane. `helpers` are the lane module's failure
@@ -119,10 +148,10 @@ export async function runHistoryBackfill(ctx, { isLaneLevel, describeReadFailure
119
148
  let deleted = 0;
120
149
  let quarantined = 0;
121
150
 
122
- const inBatch = async (ids) => {
151
+ const alreadyRead = async (ids) => {
123
152
  if (!ids.length) return new Set();
124
153
  const rows = await db.select({ key: seazonaHistoryBatchJobs.jobKey }).from(seazonaHistoryBatchJobs)
125
- .where(and(eq(seazonaHistoryBatchJobs.batchId, cursor.batchId), inArray(seazonaHistoryBatchJobs.jobKey, ids.map((id) => `case:${id}`))));
154
+ .where(inArray(seazonaHistoryBatchJobs.jobKey, ids.map((id) => `case:${id}`)));
126
155
  return new Set(rows.map((r) => r.key.slice(5)));
127
156
  };
128
157
 
@@ -134,6 +163,7 @@ export async function runHistoryBackfill(ctx, { isLaneLevel, describeReadFailure
134
163
  break;
135
164
  }
136
165
  calls += 1;
166
+ cursor.listDay = cursor.nextDay;
137
167
  const list = await client.get('orders/', { ordered: orderedParam(cursor.nextDay), page: cursor.page, pageSize });
138
168
  if (!Array.isArray(list)) throw new LaneError(`Seazona order list for ${cursor.nextDay} answer was not a list`, { code: 'seazona_list_invalid' });
139
169
  // The guide says the list answers orders placed on or after `ordered`,
@@ -149,7 +179,7 @@ export async function runHistoryBackfill(ctx, { isLaneLevel, describeReadFailure
149
179
  const live = ids.length
150
180
  ? new Set((await db.select({ id: seazonaOrders.id }).from(seazonaOrders).where(inArray(seazonaOrders.id, ids))).map((r) => r.id))
151
181
  : new Set();
152
- const seen = await inBatch(ids);
182
+ const seen = await alreadyRead(ids);
153
183
  cursor.pending = ids.filter((id) => !live.has(id) && !seen.has(id));
154
184
  if (beyond) {
155
185
  cursor.nextDay = dayOf(Date.parse(cursor.through) + DAY_MS);
@@ -163,7 +193,7 @@ export async function runHistoryBackfill(ctx, { isLaneLevel, describeReadFailure
163
193
  continue;
164
194
  }
165
195
  const id = cursor.pending.shift();
166
- if ((await inBatch([id])).size) continue;
196
+ if ((await alreadyRead([id])).size) continue;
167
197
  calls += 1;
168
198
  try {
169
199
  const detail = await client.get(`orders/${encodeURIComponent(id)}`);
@@ -8,7 +8,7 @@ import { readBillableJobs } from './reconcile.js';
8
8
  import { SEAZONA_CALL_GUARD } from './governor.js';
9
9
  import { createSeazonaRouter } from './routes.js';
10
10
  import {
11
- cancelStageMove, expiredStageMoves, handOff, readPersonSettings, requestStageMove, settleStageMoves, waitingStageMoves, writePersonSetting,
11
+ cancelStageMove, handOff, readPersonSettings, requestStageMove, settleStageMoves, waitingStageMoves, writePersonSetting,
12
12
  } from './stage-moves.js';
13
13
  import { orderPatient } from './patients.js';
14
14
  import { createSeazonaScheduler } from './scheduler.js';
@@ -148,17 +148,6 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
148
148
  if (!runtime?.client.governor) return [];
149
149
  const status = await runtime.client.governor.status();
150
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
- }
162
151
  // Issue #143: what the lanes cannot fix by themselves. A refused API
163
152
  // client already has its own alert above, so it stops no lane here.
164
153
  if (!runtime.settings.scheduled) return alerts;
@@ -1,4 +1,4 @@
1
- import { and, eq, gte, inArray, lt, sql } from 'drizzle-orm';
1
+ import { and, eq, inArray, sql } from 'drizzle-orm';
2
2
  import { FlowError } from '../../core/errors.js';
3
3
  import { orderStatusLabel } from './map.js';
4
4
  import { orderLink } from './settings.js';
@@ -9,12 +9,11 @@ import { seazonaOrders, seazonaPersonSettings, seazonaStageMoves } from './schem
9
9
  // hand-off. Flow records the move, the person makes it in Seazona, and the
10
10
  // next sync that reads the new status settles it. Until then the card shows
11
11
  // "Waiting for Seazona" in the new lane. After a day with no change the move
12
- // expires, the card goes back to Seazona's stage, and a quiet alert says so.
12
+ // expires and the card goes back to Seazona's stage. No alert follows: the
13
+ // person saw what to do in Seazona when they moved the card.
13
14
  // Every function here reads and writes Flow's database only.
14
15
 
15
16
  export const HAND_OFF_MS = 24 * 60 * 60_000;
16
- // How long an expired move stays in the alert.
17
- const EXPIRED_ALERT_MS = 7 * 24 * 60 * 60_000;
18
17
  const MAX_STATUS = 80;
19
18
 
20
19
  // Record a person's move of one order to `toStatus`, and answer the hand-off
@@ -77,15 +76,6 @@ export async function waitingStageMoves(db) {
77
76
  return new Map(rows.map((r) => [r.orderId, r]));
78
77
  }
79
78
 
80
- // The moves that expired in the last week, for the quiet alert.
81
- export async function expiredStageMoves(db, { now = Date.now() } = {}) {
82
- return db.select().from(seazonaStageMoves).where(and(
83
- eq(seazonaStageMoves.state, 'expired'),
84
- gte(seazonaStageMoves.settledAt, new Date(Number(now) - EXPIRED_ALERT_MS)),
85
- lt(seazonaStageMoves.settledAt, new Date(Number(now) + 1)),
86
- ));
87
- }
88
-
89
79
  // What the board shows for a waiting move. `link` opens the order in
90
80
  // Seazona's web app (settings.js, orderLink); `exact` is false when it opens
91
81
  // only the lab's Seazona home page.
@@ -44,12 +44,12 @@ export const SENTRY_SETUP = {
44
44
  ],
45
45
  },
46
46
  fields: [
47
- { name: 'SENTRY_DSN', label: 'DSN', kind: 'url', help: 'From the Sentry project\'s Client Keys. Browsers see it, so it is not a secret.' },
48
- { name: 'SENTRY_ENVIRONMENT', label: 'Environment', help: 'How Sentry labels this installation, for example production. Empty: production, or workspace in a Replit workspace.' },
49
- { name: 'SENTRY_CLIENT', label: 'Client', help: 'The name your support desk files this installation\'s reports under. Sent as the client tag. Empty: no client tag.' },
50
- { name: 'SENTRY_FEEDBACK', label: 'Report button', kind: 'onoff', help: 'On: everyone signed in can send a report, with a screenshot, to this Sentry project.' },
51
- { name: 'SENTRY_REPLAY', label: 'Replay', kind: 'onoff', help: 'On: each report and each browser error keeps up to the last minute of the page, as a replay with every word hidden.' },
52
- { name: 'SENTRY_TRACES_SAMPLE_RATE', label: 'Traces', help: 'The share of clicks, page loads and requests timed, from 0 (off) to 1 (all). Empty: 0.2.' },
47
+ { name: 'SENTRY_DSN', label: 'DSN', kind: 'url', advanced: true, help: 'From the Sentry project\'s Client Keys. Browsers see it, so it is not a secret.' },
48
+ { name: 'SENTRY_ENVIRONMENT', label: 'Environment', advanced: true, help: 'How Sentry labels this installation, for example production. Empty: production, or workspace in a Replit workspace.' },
49
+ { name: 'SENTRY_CLIENT', label: 'Client', advanced: true, help: 'The name your support desk files this installation\'s reports under. Sent as the client tag. Empty: no client tag.' },
50
+ { name: 'SENTRY_FEEDBACK', label: 'Report button', kind: 'onoff', default: 'on', help: 'On: everyone signed in can send a report, with a screenshot, to this Sentry project.' },
51
+ { name: 'SENTRY_REPLAY', label: 'Replay', kind: 'onoff', default: 'on', help: 'On: each report and each browser error keeps up to the last minute of the page, as a replay with every word hidden.' },
52
+ { name: 'SENTRY_TRACES_SAMPLE_RATE', label: 'Traces', advanced: true, help: 'The share of clicks, page loads and requests timed, from 0 (off) to 1 (all). Empty: 0.2.' },
53
53
  ],
54
54
  };
55
55
 
@@ -82,6 +82,7 @@ export function describeSetup({ adapter, row, env, secrets, entry, unreadable =
82
82
  choices: field.choices ?? null,
83
83
  suggest: field.suggest ?? null,
84
84
  default: field.default ?? null,
85
+ advanced: Boolean(field.advanced),
85
86
  source: fromHost ? 'host' : null,
86
87
  };
87
88
  if (field.secret) {
@@ -68,6 +68,23 @@ function xeroDate(value) {
68
68
 
69
69
  const problem = (code, message, extra = {}) => ({ code, message, ...extra });
70
70
 
71
+ // The few words the Invoices list shows for a refusal; the message says how
72
+ // to fix it. Another code shows "Not sent" (sending.js).
73
+ const SHORT = {
74
+ xero_same_reference_different_amount: 'Duplicate?',
75
+ xero_earlier_version_in_xero: 'Old version in Xero',
76
+ xero_earlier_version_unresolved: 'Check old version',
77
+ xero_tracking_missing: 'Tracking not in Xero',
78
+ xero_validation_failed: 'Xero refused',
79
+ xero_reconnect_needed: 'Reconnect Xero',
80
+ xero_token_failed: 'Reconnect Xero',
81
+ xero_unreachable: 'Xero offline',
82
+ xero_writes_off: 'Sending off',
83
+ xero_push_in_progress: 'Sending now',
84
+ xero_outcome_unknown: 'Not confirmed',
85
+ };
86
+ const shortFor = (error) => error.short ?? SHORT[error.code];
87
+
71
88
  // A person can always link the Flow contact to a Xero contact of another
72
89
  // name (create-contact.js, linkCustomer): no Xero call.
73
90
  function missingContact(name, { offerCreate }) {
@@ -292,7 +309,7 @@ export async function previewDrafts(db, runtime, organisationId, invoiceIds) {
292
309
  try {
293
310
  results.push(await previewOne(db, runtime, organisationId, invoiceId));
294
311
  } catch (error) {
295
- results.push({ invoiceId, state: 'blocked', problems: [problem(error.code ?? 'xero_preview_failed', error.message)] });
312
+ results.push({ invoiceId, state: 'blocked', problems: [problem(error.code ?? 'xero_preview_failed', error.message, { short: shortFor(error) })] });
296
313
  }
297
314
  }
298
315
  return { syncedAt, warnings, invoices: results };
@@ -414,7 +431,7 @@ export async function checkDrafts(db, runtime, { organisationId, invoiceIds }) {
414
431
  }
415
432
 
416
433
  function failed(invoiceId, error, outcome = 'failed') {
417
- return { invoiceId, outcome, error: { code: error.code ?? 'xero_push_failed', message: error.message, ...(error.details ? { details: error.details } : {}) } };
434
+ return { invoiceId, outcome, error: { code: error.code ?? 'xero_push_failed', message: error.message, ...(shortFor(error) ? { short: shortFor(error) } : {}), ...(error.details ? { details: error.details } : {}) } };
418
435
  }
419
436
 
420
437
  function summarise(results) {
@@ -489,16 +506,13 @@ async function pushOne(db, runtime, client, organisationId, invoiceId, { pushedB
489
506
  const match = await matchContactFor(db, organisationId, { contactId: invoice.contactId, name: document.contact.name });
490
507
  if (match.state !== 'matched') {
491
508
  const offerCreate = match.state === 'missing' && runtime.settings.createContacts && writesAllowed(runtime.settings);
492
- throw xeroError(match.state === 'missing' ? 'xero_contact_missing' : 'xero_contact_ambiguous',
493
- match.state === 'missing'
494
- ? missingContact(document.contact.name, { offerCreate }).message
495
- : twiceContact(document.contact.name).message,
496
- { status: 409 });
509
+ const found = match.state === 'missing' ? missingContact(document.contact.name, { offerCreate }) : twiceContact(document.contact.name);
510
+ throw xeroError(found.code, found.message, { status: 409, short: found.short });
497
511
  }
498
512
  const items = await itemsByCode(db, organisationId, document.lines.map((l) => l.itemCode));
499
513
  const sendMode = await sendModeFor(db, organisationId, invoice.contactId);
500
514
  const { draft, problems } = buildDraft(document, { settings: runtime.settings, contact: match.contact, items, sendMode });
501
- if (problems.length) throw xeroError(problems[0].code, problems.map((p) => p.message).join(' '), { status: 409 });
515
+ if (problems.length) throw xeroError(problems[0].code, problems.map((p) => p.message).join(' '), { status: 409, short: problems[0].short });
502
516
 
503
517
  // Live duplicate check: invoices in Xero with this reference, made by Flow
504
518
  // or by hand. Not needed when Flow's own copy of Xero already answered it
@@ -518,7 +532,7 @@ async function pushOne(db, runtime, client, organisationId, invoiceId, { pushedB
518
532
  });
519
533
  if (recent) {
520
534
  throw xeroError('xero_same_reference_different_amount',
521
- `Possible duplicate: Xero ${recent.InvoiceNumber || 'invoice'} (${recent.Status}, ${(cents(recent.Total) / 100).toFixed(2)}) has this contact and reference "${draft.Reference}" at another amount. Nothing was sent. Check it, then send again choosing to send anyway.`,
535
+ `Xero ${recent.InvoiceNumber || 'invoice'} has the same client and reference at ${(cents(recent.Total) / 100).toFixed(2)}. Nothing was sent. Void the extra one in Xero or in Flow.`,
522
536
  { status: 409, details: { xero: shownXero(recent) } });
523
537
  }
524
538
  }
@@ -570,12 +584,12 @@ async function dispatch(db, client, push, document, { fresh = false } = {}) {
570
584
  invoiceId: push.invoiceId,
571
585
  version: push.version,
572
586
  outcome: 'ambiguous',
573
- error: { code: 'xero_outcome_unknown', message: 'Xero did not say whether it made the draft. Send again: Flow repeats the same request within six minutes, and after that looks in Xero first, so no second invoice is made.' },
587
+ error: { code: 'xero_outcome_unknown', short: SHORT.xero_outcome_unknown, message: 'Xero did not say whether it made the draft. Send again: Flow repeats the same request within six minutes, and after that looks in Xero first, so no second invoice is made.' },
574
588
  };
575
589
  }
576
590
  if (!created?.InvoiceID) {
577
591
  await settle(db, push.id, { state: 'ambiguous', errorCode: 'xero_no_invoice_id', errorMessage: 'Xero answered without an invoice id.' });
578
- return { invoiceId: push.invoiceId, version: push.version, outcome: 'ambiguous', error: { code: 'xero_outcome_unknown', message: 'Xero answered without an invoice id. Send again to settle it.' } };
592
+ return { invoiceId: push.invoiceId, version: push.version, outcome: 'ambiguous', error: { code: 'xero_outcome_unknown', short: SHORT.xero_outcome_unknown, message: 'Xero answered without an invoice id. Send again to settle it.' } };
579
593
  }
580
594
  return verify(db, client, push, draft, document, created, 'created');
581
595
  }
@@ -671,11 +685,11 @@ async function resume(db, runtime, client, push, document) {
671
685
  && differences(inv, draft, document).filter((d) => d !== 'date').length === 0);
672
686
  } catch (error) {
673
687
  if (error.code === 'xero_rate_limited' || isCallsStopped(error)) throw error;
674
- return { invoiceId: push.invoiceId, version: push.version, outcome: 'ambiguous', error: { code: 'xero_outcome_unknown', message: 'Flow could not look in Xero to settle an earlier attempt. Send again later.' } };
688
+ return { invoiceId: push.invoiceId, version: push.version, outcome: 'ambiguous', error: { code: 'xero_outcome_unknown', short: SHORT.xero_outcome_unknown, message: 'Flow could not look in Xero to settle an earlier attempt. Send again later.' } };
675
689
  }
676
690
  if (candidates.length === 1) return verify(db, client, push, draft, document, candidates[0], 'reconciled');
677
691
  if (candidates.length > 1) {
678
- return { invoiceId: push.invoiceId, version: push.version, outcome: 'ambiguous', error: { code: 'xero_outcome_unknown', message: `Xero holds ${candidates.length} drafts that match this invoice. Delete the extra ones in Xero, then send again.` } };
692
+ return { invoiceId: push.invoiceId, version: push.version, outcome: 'ambiguous', error: { code: 'xero_outcome_unknown', short: 'Extra Xero drafts', message: `Xero holds ${candidates.length} drafts that match this invoice. Delete the extra ones in Xero, then send again.` } };
679
693
  }
680
694
  const attempt = push.attempts + 1;
681
695
  const key = idempotencyKey(push.organisationId, push.invoiceId, push.version, attempt, push.bodyHash);
@@ -51,7 +51,7 @@ const BODY = `<div class="fx-app">
51
51
  <div id="toasts" class="fx-toasts" aria-live="polite"></div>
52
52
  </div>`;
53
53
 
54
- export const APP_PATHS = ['/sign-in', '/jobs', '/invoices', '/invoices/:id', '/contacts', '/contacts/:id', '/reconcile', '/reconcile/:id', '/xero-import', '/people', '/integrations', '/integrations/:id', '/account', '/appearance', '/invoicing-settings', '/invoicing-settings/:id', '/automations', '/price-list', '/price-list/:id', '/custom-fields'];
54
+ export const APP_PATHS = ['/sign-in', '/jobs', '/captures', '/invoices', '/invoices/:id', '/contacts', '/contacts/:id', '/reconcile', '/reconcile/:id', '/xero-import', '/people', '/integrations', '/integrations/:id', '/account', '/appearance', '/invoicing-settings', '/invoicing-settings/:id', '/automations', '/price-list', '/price-list/:id', '/custom-fields'];
55
55
 
56
56
  export function appPage({ defaultFamily }) {
57
57
  return renderPage({