@arcforgelabs/flow 2026.10.51 → 2026.10.53
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/api/captures.js +26 -4
- package/server/api/invoicing.js +1 -1
- package/server/app.js +2 -0
- package/server/auth/people.js +3 -3
- package/server/core/ai.js +2 -1
- package/server/core/capture-match.js +158 -0
- package/server/core/captures.js +141 -7
- package/server/core/invoice-rules.js +16 -5
- package/server/core/reader.js +1 -10
- package/server/core/sending.js +7 -3
- package/server/db/migrations/0024_rule_icon.sql +5 -0
- package/server/db/migrations/meta/0024_snapshot.json +4703 -0
- package/server/db/migrations/meta/_journal.json +8 -1
- package/server/db/schema.js +3 -0
- package/server/integrations/seazona/history/backfill.js +39 -9
- package/server/integrations/seazona/index.js +1 -12
- package/server/integrations/seazona/stage-moves.js +3 -13
- package/server/integrations/sentry/index.js +6 -6
- package/server/integrations/setup.js +1 -0
- package/server/integrations/xero/drafts.js +27 -13
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +61 -20
- package/server/ui/capture-fields.js +17 -0
- package/server/ui/captures.js +289 -0
- package/server/ui/columns.js +68 -3
- package/server/ui/custom-fields.js +3 -9
- package/server/ui/flow.css +94 -18
- package/server/ui/invoice-rules.js +10 -2
- package/server/ui/invoicing-settings.js +2 -8
- package/server/ui/invoicing.js +12 -8
- package/server/ui/job-view.js +6 -3
- package/server/ui/jobs.js +40 -117
- package/server/ui/kit.js +86 -1
- package/server/ui/list-views.js +47 -3
- package/server/ui/mobile.js +45 -5
- package/server/ui/nav-pins.js +4 -2
- package/server/ui/price-list.js +138 -69
- package/server/ui/routes.js +4 -1
- package/server/ui/saved-views.js +2 -0
- package/server/ui/view-bar.js +61 -2
package/server/db/schema.js
CHANGED
|
@@ -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,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
|
15
|
-
//
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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,
|
|
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,
|
|
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
|
|
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
|
-
|
|
493
|
-
|
|
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
|
-
`
|
|
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);
|
package/server/ui/app-page.js
CHANGED
|
@@ -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({
|
package/server/ui/app.js
CHANGED
|
@@ -10,6 +10,7 @@ import { getAppearance, onAppearanceChange, setAppearance, loadFonts } from '/as
|
|
|
10
10
|
import { flowClerkAppearance } from '/assets/ui/clerk-appearance.js';
|
|
11
11
|
import { ACCENT_PRESETS, normalizeAccent, THEME_FAMILIES, tokensFor } from '/assets/theme/themes.js';
|
|
12
12
|
import { renderInvoice, renderInvoiceList } from '/assets/ui/invoicing.js';
|
|
13
|
+
import { renderCaptures } from '/assets/ui/captures.js';
|
|
13
14
|
import { renderContacts } from '/assets/ui/contacts.js';
|
|
14
15
|
import { automationSection, renderAutomations } from '/assets/ui/automations.js';
|
|
15
16
|
import { renderInvoicingSettings, sendModeSection } from '/assets/ui/invoicing-settings.js';
|
|
@@ -25,7 +26,7 @@ import { isSearchKey, openSearch } from '/assets/ui/search.js';
|
|
|
25
26
|
import { alertStrip } from '/assets/ui/alerts.js';
|
|
26
27
|
import { initialsOf, personLabel } from '/assets/ui/person.js';
|
|
27
28
|
import { startTips } from '/assets/ui/tip.js';
|
|
28
|
-
import { crumbs, dialog, edgeFade, fieldError, h, holdButton, linksOpenWith, listFrame, listHead, notice, pageHeader, pickOne, rowMenu, sectionTitle, settingRow, tileCard, tileSection, toast } from '/assets/ui/kit.js';
|
|
29
|
+
import { crumbs, dialog, edgeFade, fieldError, h, holdButton, iconPicker, linksOpenWith, listFrame, listHead, notice, pageHeader, pickOne, rowMenu, sectionTitle, settingRow, tileCard, tileSection, toast } from '/assets/ui/kit.js';
|
|
29
30
|
import { NAV_DEFAULT, NAV_LOCKED } from '/assets/ui/nav-pins.js';
|
|
30
31
|
|
|
31
32
|
const $ = (id) => document.getElementById(id);
|
|
@@ -152,6 +153,7 @@ let me = null;
|
|
|
152
153
|
// cannot be unpinned (nav-pins.js).
|
|
153
154
|
const VIEWS = {
|
|
154
155
|
jobs: { label: 'Jobs', icon: 'jobs', render: (main) => renderJobs(main, ui()) },
|
|
156
|
+
captures: { label: 'Capture', icon: 'camera', render: (main) => renderCaptures(main, ui()) },
|
|
155
157
|
invoices: { label: 'Invoices', icon: 'invoices', render: (main, id) => (id ? renderInvoice(main, ui(), id) : renderInvoiceList(main, ui())) },
|
|
156
158
|
contacts: { label: 'Contacts', icon: 'contacts', render: (main, id) => renderContacts(main, ui(), id) },
|
|
157
159
|
reconcile: { label: 'Reconcile', icon: 'reconcile', render: (main, id) => renderReconcile(main, ui(), id) },
|
|
@@ -174,6 +176,7 @@ const ICON_PATHS = {
|
|
|
174
176
|
jobs: '<rect x="3" y="4" width="5" height="16" rx="1"/><rect x="10" y="4" width="5" height="11" rx="1"/><rect x="17" y="4" width="4" height="14" rx="1"/>',
|
|
175
177
|
invoices: '<path d="M6 3h9l4 4v14H6z"/><path d="M14 3v5h5M9 13h7M9 17h5"/>',
|
|
176
178
|
contacts: '<rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="9" cy="10" r="2"/><path d="M6 16c.4-1.6 1.5-2.4 3-2.4s2.6.8 3 2.4M14 9h5M14 13h4"/>',
|
|
179
|
+
camera: '<path d="M4 8h3l2-3h6l2 3h3v11H4z"/><circle cx="12" cy="13" r="3.5"/>',
|
|
177
180
|
bolt: '<path d="M13 3L5 13h6l-1 8 8-10h-6z"/>',
|
|
178
181
|
rules: '<path d="M3 6l2 2 4-4M3 16l2 2 4-4M13 6h8M13 12h8M13 18h8"/>',
|
|
179
182
|
reconcile: '<path d="M4 8h13M13 4l4 4-4 4M20 16H7M11 12l-4 4 4 4"/>',
|
|
@@ -332,7 +335,7 @@ function navigate(event) {
|
|
|
332
335
|
// The shared pieces a view module (invoicing.js) builds with. Its `go`
|
|
333
336
|
// takes a path, or a link's click event.
|
|
334
337
|
const ui = () => ({
|
|
335
|
-
h, flow, toast, notice, fieldError, pageHeader, listHead, listFrame, edgeFade, sectionTitle, settingRow, dialog, pickOne, me, holdButton, tileCard, tileSection, crumbs,
|
|
338
|
+
h, flow, toast, notice, fieldError, pageHeader, listHead, listFrame, edgeFade, sectionTitle, settingRow, dialog, pickOne, iconPicker, me, holdButton, tileCard, tileSection, crumbs,
|
|
336
339
|
go: (target, options) => (typeof target === 'string' ? go(target, options) : navigate(target)),
|
|
337
340
|
});
|
|
338
341
|
|
|
@@ -741,6 +744,9 @@ async function renderIntegration(main, id) {
|
|
|
741
744
|
// Settings: closed on a phone, open on a wider screen, then as the person
|
|
742
745
|
// leaves it while the page redraws (Connect).
|
|
743
746
|
let settingsOpen = !window.matchMedia?.('(max-width: 639px)').matches;
|
|
747
|
+
// Advanced: the technical settings and the host's values. Open while the
|
|
748
|
+
// integration is off, so Connect shows every setting it needs.
|
|
749
|
+
let advancedOpen = null;
|
|
744
750
|
const alerts = alertStrip(ui(), 'integrations', {
|
|
745
751
|
scope: id,
|
|
746
752
|
onLoad: ({ danger }) => {
|
|
@@ -753,10 +759,11 @@ async function renderIntegration(main, id) {
|
|
|
753
759
|
shown = current;
|
|
754
760
|
const inputs = new Map();
|
|
755
761
|
const rows = [];
|
|
762
|
+
const advanced = [];
|
|
756
763
|
for (const field of current.fields) {
|
|
757
764
|
if (field.source === 'host') {
|
|
758
765
|
// The host sets it: a value to read, not a box to type in.
|
|
759
|
-
|
|
766
|
+
advanced.push(settingRow({
|
|
760
767
|
label: field.label,
|
|
761
768
|
help: 'Set by whoever hosts Flow.',
|
|
762
769
|
control: field.secret ? h('span', { class: 'fx-dim' }, 'Set') : h('code', { class: 'fx-setting__value' }, field.value),
|
|
@@ -778,7 +785,7 @@ async function renderIntegration(main, id) {
|
|
|
778
785
|
}
|
|
779
786
|
inputs.set(field.name, { field, input });
|
|
780
787
|
const suggestion = field.suggest === 'callback' ? `${location.origin}/api/${current.id}/oauth/callback` : null;
|
|
781
|
-
rows.push(settingRow({
|
|
788
|
+
(field.advanced ? advanced : rows).push(settingRow({
|
|
782
789
|
label: field.label,
|
|
783
790
|
hint: field.unreadable ? 'Stored with another key. Enter it again.' : null,
|
|
784
791
|
help: field.help,
|
|
@@ -875,9 +882,12 @@ async function renderIntegration(main, id) {
|
|
|
875
882
|
h('summary', { class: 'fx-int-section__title' }, 'Set up'),
|
|
876
883
|
h('ol', { class: 'fx-steps' }, guide.steps.map((step) => h('li', {}, step))),
|
|
877
884
|
guide.link ? h('a', { class: 'fx-btn fx-btn--sm', href: guide.link, target: '_blank', rel: 'noopener' }, `${current.name} site ↗`) : null) : null,
|
|
878
|
-
rows.length ? h('details', { class: 'fx-int-section fx-int-fold', open: settingsOpen, ontoggle: (event) => { settingsOpen = event.currentTarget.open; } },
|
|
885
|
+
rows.length || advanced.length ? h('details', { class: 'fx-int-section fx-int-fold', open: settingsOpen, ontoggle: (event) => { settingsOpen = event.currentTarget.open; } },
|
|
879
886
|
h('summary', { class: 'fx-int-section__title' }, 'Settings'),
|
|
880
|
-
h('div', { class: 'fx-settings' }, rows),
|
|
887
|
+
rows.length ? h('div', { class: 'fx-settings' }, rows) : null,
|
|
888
|
+
advanced.length ? h('details', { class: 'fx-int-fold fx-int-fold--sub', open: advancedOpen ?? !on, ontoggle: (event) => { advancedOpen = event.currentTarget.open === !on ? null : event.currentTarget.open; } },
|
|
889
|
+
h('summary', {}, 'Advanced'),
|
|
890
|
+
h('div', { class: 'fx-settings' }, advanced)) : null,
|
|
881
891
|
canChange && on && inputs.size ? h('div', { class: 'fx-row fx-row--end' }, h('button', { class: 'fx-btn', type: 'button', onclick: () => save(undefined) }, 'Save changes')) : null) : null,
|
|
882
892
|
].filter(Boolean)),
|
|
883
893
|
h('aside', { class: 'fx-int-page__meta' },
|
|
@@ -980,21 +990,26 @@ function xeroImportSection(organisations) {
|
|
|
980
990
|
}
|
|
981
991
|
|
|
982
992
|
// Seazona's history read (docs/SEAZONA.md, "The API backfill"): past orders
|
|
983
|
-
// from a start day, a few calls a run inside the rate limit. Start
|
|
984
|
-
// call no Seazona; the background lane reads
|
|
985
|
-
// its progress again every
|
|
993
|
+
// from a start day, a few calls a run inside the rate limit. Start, Resume
|
|
994
|
+
// and Stop call no Seazona; the background lane reads, and skips orders Flow
|
|
995
|
+
// read before. While it runs, the card reads its progress again every 5
|
|
996
|
+
// seconds, from Flow only, and counts down to the lane's next run.
|
|
986
997
|
function seazonaHistorySection() {
|
|
987
998
|
const canAct = ['owner', 'admin'].includes(me?.person?.role);
|
|
988
999
|
const yearAgo = new Date(Date.now() - 365 * 86_400_000).toISOString().slice(0, 10);
|
|
989
|
-
const from = h('input', { class: 'fx-input', type: 'date', 'aria-label': 'Read from', value: yearAgo, disabled: !canAct });
|
|
990
|
-
const status = h('span', {}, 'Checking…');
|
|
991
|
-
const jobs = h('span', { class: 'fx-num' }, '…');
|
|
1000
|
+
const from = h('input', { class: 'fx-input fx-history__day', type: 'date', 'aria-label': 'Read from', value: yearAgo, disabled: !canAct });
|
|
1001
|
+
const status = h('span', { class: 'fx-history__status' }, 'Checking…');
|
|
992
1002
|
const control = h('span', { class: 'fx-row' });
|
|
1003
|
+
const bar = h('progress', { class: 'fx-history__bar', max: 1, value: 0, 'aria-label': 'Read so far' });
|
|
1004
|
+
const barRow = settingRow({ label: 'Progress', hint: h('span', {}), control: bar, wide: true });
|
|
1005
|
+
barRow.hidden = true;
|
|
993
1006
|
const day = (value) => new Date(`${value}T00:00:00Z`).toLocaleDateString('en-AU', { dateStyle: 'medium', timeZone: 'UTC' });
|
|
994
1007
|
const section = h('section', { class: 'fx-set fx-int-section fx-card', style: { padding: '8px 20px 16px' } },
|
|
995
1008
|
h('h2', { class: 'fx-set__title' }, 'History'),
|
|
996
|
-
settingRow({ label: 'Read from', hint: status, help: 'Reads past Seazona orders into Flow, 10 every 2 minutes. Each job waits on hold and never drafts by itself.', control }),
|
|
997
|
-
|
|
1009
|
+
settingRow({ label: 'Read from', hint: status, help: 'Reads past Seazona orders into Flow, 10 every 2 minutes. Orders Flow read before are skipped. Each job waits on hold and never drafts by itself.', control }),
|
|
1010
|
+
barRow);
|
|
1011
|
+
let timer = null;
|
|
1012
|
+
let tick = null;
|
|
998
1013
|
const press = async (button, path, body) => {
|
|
999
1014
|
button.disabled = true;
|
|
1000
1015
|
try {
|
|
@@ -1006,7 +1021,14 @@ function seazonaHistorySection() {
|
|
|
1006
1021
|
}
|
|
1007
1022
|
load();
|
|
1008
1023
|
};
|
|
1024
|
+
const start = (label, body, primary = true) => h('button', {
|
|
1025
|
+
class: `fx-btn fx-btn--sm${primary ? ' fx-btn--primary' : ''}`, type: 'button', disabled: !canAct,
|
|
1026
|
+
onclick: (event) => press(event.currentTarget, '/api/seazona/history/backfill', body()),
|
|
1027
|
+
}, label);
|
|
1009
1028
|
const load = async () => {
|
|
1029
|
+
clearTimeout(timer);
|
|
1030
|
+
clearInterval(tick);
|
|
1031
|
+
if (!section.isConnected && timer !== null) return;
|
|
1010
1032
|
let body;
|
|
1011
1033
|
try {
|
|
1012
1034
|
body = await flow('/api/seazona/history');
|
|
@@ -1015,15 +1037,34 @@ function seazonaHistorySection() {
|
|
|
1015
1037
|
return;
|
|
1016
1038
|
}
|
|
1017
1039
|
const run = body.backfill;
|
|
1018
|
-
|
|
1040
|
+
barRow.hidden = !run;
|
|
1041
|
+
if (run) {
|
|
1042
|
+
const span = Date.parse(run.through) - Date.parse(run.from) + 86_400_000;
|
|
1043
|
+
const at = run.running ? run.readingDay ?? run.through : run.resumeFrom ?? run.through;
|
|
1044
|
+
bar.value = run.running || run.resumeFrom ? Math.min(1, Math.max(0, (Date.parse(at) - Date.parse(run.from)) / span)) : 1;
|
|
1045
|
+
barRow.querySelector('.fx-setting__hint').replaceChildren(`${day(run.from)} to ${day(run.through)}`);
|
|
1046
|
+
}
|
|
1019
1047
|
if (run?.running) {
|
|
1020
|
-
|
|
1048
|
+
const what = { waiting: 'Waiting', failed: 'Failed' }[run.phase] ?? `Reading ${day(run.readingDay ?? run.through)}`;
|
|
1049
|
+
const reading = `${what} · ${run.ordersRead} read${run.waitingToRead ? ` · ${run.waitingToRead} to read` : ''}`;
|
|
1050
|
+
const next = h('span', { class: 'fx-dim' });
|
|
1051
|
+
const count = () => {
|
|
1052
|
+
const left = Math.round((Date.parse(run.nextRunAt) - Date.now()) / 1000);
|
|
1053
|
+
next.replaceChildren(left > 0 ? `· ${run.phase === 'running' ? 'next' : 'retry'} in ${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}` : '· reading now');
|
|
1054
|
+
};
|
|
1055
|
+
if (run.nextRunAt && run.phase !== 'failed') { count(); tick = setInterval(count, 1000); }
|
|
1056
|
+
status.replaceChildren(
|
|
1057
|
+
run.phase === 'running' ? h('span', { class: 'fx-spinner fx-spinner--sm', 'aria-hidden': 'true' }) : '',
|
|
1058
|
+
reading, next, run.cause ? h('span', { class: 'fx-dim' }, `· ${run.cause}`) : '');
|
|
1021
1059
|
control.replaceChildren(h('button', { class: 'fx-btn fx-btn--sm', type: 'button', disabled: !canAct, onclick: (event) => press(event.currentTarget, '/api/seazona/history/backfill/stop') }, 'Stop'));
|
|
1022
|
-
|
|
1023
|
-
|
|
1060
|
+
} else if (run?.resumeFrom) {
|
|
1061
|
+
status.replaceChildren(`Stopped at ${day(run.resumeFrom)} · ${run.ordersRead} read`);
|
|
1062
|
+
control.replaceChildren(from, start('Start', () => ({ from: from.value }), false), start('Resume', () => ({ from: run.resumeFrom, through: run.through })));
|
|
1063
|
+
} else {
|
|
1064
|
+
status.replaceChildren(run ? `Done · ${run.ordersRead} read` : 'Not started');
|
|
1065
|
+
control.replaceChildren(from, start('Start', () => ({ from: from.value })));
|
|
1024
1066
|
}
|
|
1025
|
-
|
|
1026
|
-
control.replaceChildren(from, h('button', { class: 'fx-btn fx-btn--sm fx-btn--primary', type: 'button', disabled: !canAct, onclick: (event) => press(event.currentTarget, '/api/seazona/history/backfill', { from: from.value }) }, 'Start'));
|
|
1067
|
+
timer = setTimeout(() => { if (section.isConnected && run?.running) load(); }, 5_000);
|
|
1027
1068
|
};
|
|
1028
1069
|
load();
|
|
1029
1070
|
return section;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// The job fields the photo reader asks for (server/core/reader.js) and the
|
|
2
|
+
// capture screens label (server/ui/captures.js). No DOM, so both can use it.
|
|
3
|
+
// `needed` fields are read again by the larger model when they are not sure.
|
|
4
|
+
export const JOB_FIELDS = Object.freeze([
|
|
5
|
+
Object.freeze({ key: 'case_number', label: 'Case number', needed: true }),
|
|
6
|
+
Object.freeze({ key: 'practice', label: 'Practice', needed: true }),
|
|
7
|
+
Object.freeze({ key: 'dentist', label: 'Dentist', needed: false }),
|
|
8
|
+
Object.freeze({ key: 'work', label: 'Work', needed: true }),
|
|
9
|
+
Object.freeze({ key: 'teeth', label: 'Teeth', needed: false }),
|
|
10
|
+
Object.freeze({ key: 'shade', label: 'Shade', needed: false }),
|
|
11
|
+
Object.freeze({ key: 'due_date', label: 'Due date', needed: false }),
|
|
12
|
+
Object.freeze({ key: 'notes', label: 'Notes', needed: false }),
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
// Below this a needed field is read again (FLOW_READER_SURE_LINE can move
|
|
16
|
+
// that line), and the screens mark a value as not sure (amber).
|
|
17
|
+
export const SURE_LINE = 0.8;
|