@arcforgelabs/flow 2026.10.53 → 2026.11.1
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/README.md +1 -0
- package/SECURITY.md +7 -4
- package/npm-shrinkwrap.json +442 -4
- package/package.json +8 -3
- package/server/agents/auth-worker.js +7 -0
- package/server/agents/auth.js +50 -0
- package/server/agents/gate.js +138 -0
- package/server/agents/grants.js +51 -0
- package/server/agents/reads.js +47 -0
- package/server/agents/server.js +74 -0
- package/server/agents/settings.js +24 -0
- package/server/app.js +9 -0
- package/server/core/ledger.js +90 -16
- package/server/core/line-mapping.js +2 -2
- package/server/core/read-before-act.js +2 -2
- package/server/core/reconcile.js +15 -1
- package/server/core/sending.js +20 -2
- package/server/db/migrations/0025_powerful_leopardon.sql +1 -0
- package/server/db/migrations/0026_bitter_sersi.sql +38 -0
- package/server/db/migrations/meta/0025_snapshot.json +4709 -0
- package/server/db/migrations/meta/0026_snapshot.json +4926 -0
- package/server/db/migrations/meta/_journal.json +14 -0
- package/server/db/schema.js +38 -1
- package/server/index.js +9 -1
- package/server/integrations/port.js +1 -0
- package/server/integrations/seazona/history/backfill.js +3 -1
- package/server/integrations/seazona/history/index.js +4 -7
- package/server/integrations/seazona/index.js +5 -1
- package/server/integrations/seazona/lanes.js +44 -27
- package/server/integrations/seazona/ledger.js +17 -6
- package/server/integrations/seazona/reconcile.js +12 -0
- package/server/rate-limit.js +2 -1
- package/server/ui/app.js +74 -62
- package/server/ui/flow.css +36 -5
- package/server/ui/invoicing.js +34 -7
- package/server/ui/mobile.js +17 -0
- package/server/ui/routes.js +1 -1
- package/server/ui/sync-status.js +67 -0
|
@@ -176,6 +176,20 @@
|
|
|
176
176
|
"when": 1793233782043,
|
|
177
177
|
"tag": "0024_rule_icon",
|
|
178
178
|
"breakpoints": true
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
"idx": 25,
|
|
182
|
+
"version": "7",
|
|
183
|
+
"when": 1793972387349,
|
|
184
|
+
"tag": "0025_powerful_leopardon",
|
|
185
|
+
"breakpoints": true
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
"idx": 26,
|
|
189
|
+
"version": "7",
|
|
190
|
+
"when": 1794019099844,
|
|
191
|
+
"tag": "0026_bitter_sersi",
|
|
192
|
+
"breakpoints": true
|
|
179
193
|
}
|
|
180
194
|
]
|
|
181
195
|
}
|
package/server/db/schema.js
CHANGED
|
@@ -112,7 +112,8 @@ export const invoices = pgTable('invoices', {
|
|
|
112
112
|
readyAt: timestamp('ready_at', { withTimezone: true }),
|
|
113
113
|
readyBy: uuid('ready_by').references(() => people.id, { onDelete: 'set null' }),
|
|
114
114
|
// Sending to the accounting system (server/core/sending.js): queued by a
|
|
115
|
-
// person, then sent or failed. `sendError` is { code, message
|
|
115
|
+
// person, then sent or failed. `sendError` is { code, message, short?, xero? }
|
|
116
|
+
// while failed. `xero` is the matched invoice { invoiceId, number, status }.
|
|
116
117
|
sendState: text('send_state'),
|
|
117
118
|
sendError: jsonb('send_error'),
|
|
118
119
|
sendRequestedBy: uuid('send_requested_by').references(() => people.id, { onDelete: 'set null' }),
|
|
@@ -259,6 +260,8 @@ export const billingObligations = pgTable('billing_obligations', {
|
|
|
259
260
|
// The lines as the work system reported them, normalised.
|
|
260
261
|
lines: jsonb('lines').notNull().default(sql`'[]'::jsonb`),
|
|
261
262
|
lineFingerprint: text('line_fingerprint').notNull(),
|
|
263
|
+
// Exact source aggregates for a job billed alone. Missing fields are unknown.
|
|
264
|
+
sourceTotals: jsonb('source_totals'),
|
|
262
265
|
subtotalCents: integer('subtotal_cents').notNull(),
|
|
263
266
|
taxCents: integer('tax_cents').notNull(),
|
|
264
267
|
totalCents: integer('total_cents').notNull(),
|
|
@@ -908,3 +911,37 @@ export const captures = pgTable('captures', {
|
|
|
908
911
|
check('captures_sha256_valid', sql`${t.fileSha256} ~ '^[0-9a-f]{64}$'`),
|
|
909
912
|
check('captures_problem_valid', sql`${t.problem} is null or length(${t.problem}) between 1 and 200`),
|
|
910
913
|
]);
|
|
914
|
+
|
|
915
|
+
// A read grant binds one person and client to one installation and organisation.
|
|
916
|
+
export const agentGrants = pgTable('agent_grants', {
|
|
917
|
+
id: id(),
|
|
918
|
+
personId: uuid('person_id').notNull().references(() => people.id, { onDelete: 'cascade' }),
|
|
919
|
+
clientId: text('client_id').notNull(),
|
|
920
|
+
appName: text('app_name').notNull(),
|
|
921
|
+
resource: text('resource').notNull(),
|
|
922
|
+
organisationId: organisationId().references(() => organisations.id),
|
|
923
|
+
accessLevel: text('access_level').notNull().default('read'),
|
|
924
|
+
createdAt: createdAt(),
|
|
925
|
+
lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
|
|
926
|
+
revokedAt: timestamp('revoked_at', { withTimezone: true }),
|
|
927
|
+
failureDay: date('failure_day'),
|
|
928
|
+
failureCount: integer('failure_count').notNull().default(0),
|
|
929
|
+
pausedAt: timestamp('paused_at', { withTimezone: true }),
|
|
930
|
+
}, (t) => [
|
|
931
|
+
unique('agent_grants_person_client_resource').on(t.personId, t.clientId, t.resource),
|
|
932
|
+
check('agent_grants_read_only', sql`access_level = 'read'`),
|
|
933
|
+
]);
|
|
934
|
+
|
|
935
|
+
// No arguments, token, source evidence, error text or patient data belongs here.
|
|
936
|
+
// Historical UUID snapshots deliberately have no foreign keys: removing a
|
|
937
|
+
// person or grant must not erase or rewrite the retained read history.
|
|
938
|
+
export const agentAudit = pgTable('agent_audit', {
|
|
939
|
+
id: id(),
|
|
940
|
+
createdAt: createdAt(),
|
|
941
|
+
grantId: uuid('grant_id').notNull(),
|
|
942
|
+
personId: uuid('person_id').notNull(),
|
|
943
|
+
tool: text('tool').notNull(),
|
|
944
|
+
status: integer('status').notNull(),
|
|
945
|
+
rowCount: integer('row_count').notNull().default(0),
|
|
946
|
+
durationMs: integer('duration_ms').notNull(),
|
|
947
|
+
}, (t) => [index('agent_audit_created').on(t.createdAt)]);
|
package/server/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createApp } from './app.js';
|
|
3
|
+
import { createMcpVerifier } from './agents/auth.js';
|
|
4
|
+
import { resolveMcpSettings } from './agents/settings.js';
|
|
3
5
|
import { createClerkProxy, ensureClerkAccount, loadClerkAdapter, resolveClerkProxyPath } from './auth/clerk.js';
|
|
4
6
|
import { seedFirstOwner } from './auth/people.js';
|
|
5
7
|
import { createPersonAuth } from './auth/session.js';
|
|
@@ -31,8 +33,14 @@ const port = Number(process.env.FLOW_PORT || process.env.PORT || 19300);
|
|
|
31
33
|
|
|
32
34
|
// Settings first: a malformed setting stops boot before anything runs.
|
|
33
35
|
let settings;
|
|
36
|
+
let mcp;
|
|
34
37
|
try {
|
|
35
38
|
settings = resolveSignInSettings();
|
|
39
|
+
const mcpSettings = resolveMcpSettings();
|
|
40
|
+
if (mcpSettings) {
|
|
41
|
+
if (settings.mode !== 'clerk') throw new Error('MCP requires FLOW_AUTH=clerk');
|
|
42
|
+
mcp = { ...mcpSettings, publishableKey: settings.keys.publishableKey, verify: createMcpVerifier({ keys: settings.keys, ...mcpSettings }) };
|
|
43
|
+
}
|
|
36
44
|
} catch (error) {
|
|
37
45
|
console.error(`[auth] ${error.message}`);
|
|
38
46
|
process.exit(1);
|
|
@@ -201,7 +209,7 @@ const trustProxy = process.env.REPL_ID || process.env.REPLIT_DEPLOYMENT ? 1 : fa
|
|
|
201
209
|
const updates = createUpdateChecker();
|
|
202
210
|
|
|
203
211
|
createApp({
|
|
204
|
-
database, schemaJournals: expectedSchema({ adapters: shippedAdapters }).journals, auth, trustProxy, updates, integrations, secrets, defaultFamily, log, tracing, storage,
|
|
212
|
+
database, mcp, schemaJournals: expectedSchema({ adapters: shippedAdapters }).journals, auth, trustProxy, updates, integrations, secrets, defaultFamily, log, tracing, storage,
|
|
205
213
|
publicOrigin: process.env.FLOW_PUBLIC_ORIGIN,
|
|
206
214
|
reader: reader ? { on: true, schedule: reader.schedule } : null,
|
|
207
215
|
}).listen(port, () => {
|
|
@@ -24,6 +24,7 @@ export const CAPABILITIES = Object.freeze([
|
|
|
24
24
|
'pullPayments', // payments since a cursor, matched to invoices and bills
|
|
25
25
|
'checkInvoice', // does the accounting system hold an invoice for a billable job? (the ledger's liveCheck)
|
|
26
26
|
'billableJobs', // billable jobs by day from the work system's copy in Flow, for the daily reconcile; never a live call
|
|
27
|
+
'workJobPresence', // { organisationId, jobRefs } -> { presentRefs }: requested jobs still present in the work copy, without status, date or line filters; deleted jobs are absent; never a live call
|
|
27
28
|
'jobReferenceFields', // { patient }: the fields a job gives the invoice Reference (server/core/references.js)
|
|
28
29
|
'jobLink', // { url, exact } or null: the link to one job in the work system's own web app, for "Open in" on a job's problem; never a live call
|
|
29
30
|
'listWork', // the work system's jobs from its copy in Flow, open and lately finished, for the Jobs board; a job may carry `pendingMove`
|
|
@@ -78,7 +78,7 @@ export async function stopBackfill(db, { now = Date.now } = {}) {
|
|
|
78
78
|
const [state] = await db.select().from(seazonaLaneState).where(eq(seazonaLaneState.lane, LANE));
|
|
79
79
|
const cursor = state?.cursor;
|
|
80
80
|
if (!cursor?.batchId || cursor.done) throw Object.assign(new FlowError('conflict', 'No history backfill is running'), { status: 409 });
|
|
81
|
-
const stopped = { ...cursor, done: true, stopped: true };
|
|
81
|
+
const stopped = { ...cursor, done: true, stopped: true, endedAt: at.toISOString() };
|
|
82
82
|
await db.update(seazonaLaneState).set({ cursor: stopped, updatedAt: at }).where(eq(seazonaLaneState.lane, LANE));
|
|
83
83
|
await finishApiBatch(db, cursor.batchId, { at });
|
|
84
84
|
return describeBackfill(stopped);
|
|
@@ -91,6 +91,7 @@ export function describeBackfill(cursor) {
|
|
|
91
91
|
from: cursor.from,
|
|
92
92
|
through: cursor.through,
|
|
93
93
|
startedAt: cursor.startedAt ?? null,
|
|
94
|
+
endedAt: cursor.endedAt ?? null,
|
|
94
95
|
nextDay: cursor.done ? null : cursor.nextDay,
|
|
95
96
|
// The day whose orders it reads now: the last day listed while orders
|
|
96
97
|
// from it wait, else the next day to list.
|
|
@@ -160,6 +161,7 @@ export async function runHistoryBackfill(ctx, { isLaneLevel, describeReadFailure
|
|
|
160
161
|
if (!cursor.pending.length) {
|
|
161
162
|
if (cursor.nextDay > cursor.through) {
|
|
162
163
|
cursor.done = true;
|
|
164
|
+
cursor.endedAt = new Date(at).toISOString();
|
|
163
165
|
break;
|
|
164
166
|
}
|
|
165
167
|
calls += 1;
|
|
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
import { and, asc, desc, eq, gt, inArray, sql } from 'drizzle-orm';
|
|
3
3
|
import { FlowError } from '../../../core/errors.js';
|
|
4
4
|
import { listClients } from '../feed.js';
|
|
5
|
+
import { ledgerLine } from '../ledger.js';
|
|
5
6
|
import { hashOf, parseSeazonaDate, text } from '../map.js';
|
|
6
7
|
import {
|
|
7
8
|
seazonaClients,
|
|
@@ -517,13 +518,9 @@ export async function readHistoryWork(db, { cursor = null, limit = 200 } = {}) {
|
|
|
517
518
|
shippedOn: job.shippedDate,
|
|
518
519
|
jobDate: job.jobDate ?? job.dueDate ?? job.shippedDate,
|
|
519
520
|
sourceStatus: job.status,
|
|
520
|
-
lines: (job.lines ?? []).map(
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
quantity: 1,
|
|
524
|
-
unitAmountCents: l.amountCents ?? undefined,
|
|
525
|
-
taxCode: typeof l.taxable === 'boolean' ? (l.taxable ? 'GST' : 'GST_FREE') : undefined,
|
|
526
|
-
})),
|
|
521
|
+
lines: (job.lines ?? []).map(ledgerLine),
|
|
522
|
+
// The CSV reports a total, but no invoice tax allocation.
|
|
523
|
+
sourceTotals: job.totalCents == null ? null : { totalCents: job.totalCents },
|
|
527
524
|
observedAt: job.lastChangedAt.toISOString(),
|
|
528
525
|
actor: 'seazona:history_import',
|
|
529
526
|
},
|
|
@@ -4,7 +4,7 @@ import { createSeazonaClient } from './client.js';
|
|
|
4
4
|
import { listClients, listProducts, listWork, readBillableWork } from './feed.js';
|
|
5
5
|
import { historyJobIssues, historySummary, readHistoryWork } from './history/index.js';
|
|
6
6
|
import { readSeazonaSyncHealth, runSeazonaSync } from './lanes.js';
|
|
7
|
-
import { readBillableJobs } from './reconcile.js';
|
|
7
|
+
import { readBillableJobs, readWorkJobPresence } from './reconcile.js';
|
|
8
8
|
import { SEAZONA_CALL_GUARD } from './governor.js';
|
|
9
9
|
import { createSeazonaRouter } from './routes.js';
|
|
10
10
|
import {
|
|
@@ -200,6 +200,10 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
|
|
|
200
200
|
return readBillableJobs(current().db, { from, to });
|
|
201
201
|
},
|
|
202
202
|
|
|
203
|
+
async workJobPresence({ jobRefs }) {
|
|
204
|
+
return readWorkJobPresence(current().db, { jobRefs });
|
|
205
|
+
},
|
|
206
|
+
|
|
203
207
|
// Seazona clients by id and company name, for the identity map. Reads
|
|
204
208
|
// Flow's copy only.
|
|
205
209
|
async listCustomers() {
|
|
@@ -430,47 +430,62 @@ const laneRuns = {
|
|
|
430
430
|
|
|
431
431
|
async orders_recent(ctx) {
|
|
432
432
|
const { db, client, sync, at } = ctx;
|
|
433
|
-
const
|
|
434
|
-
const lookbackStart =
|
|
435
|
-
const
|
|
436
|
-
const
|
|
437
|
-
const partialDay = Date.parse(partial?.day ?? '');
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
433
|
+
const today = floorUtcDay(at.getTime());
|
|
434
|
+
const lookbackStart = today - sync.orderLookbackDays * DAY_MS;
|
|
435
|
+
const cursor = ctx.state?.cursor ?? {};
|
|
436
|
+
const doneThrough = Date.parse(cursor.completedThrough ?? '');
|
|
437
|
+
const partialDay = Date.parse(cursor.partial?.day ?? '');
|
|
438
|
+
const todayPartialDay = Date.parse(cursor.todayPartial?.day ?? '');
|
|
439
|
+
let partial = partialDay >= lookbackStart && partialDay < today ? cursor.partial : null;
|
|
440
|
+
// A partial newest day becomes an older day at midnight. Keep its page
|
|
441
|
+
// unless an older partial already owns the rolling walk; that walk will
|
|
442
|
+
// revisit the former newest day on its next cycle too.
|
|
443
|
+
if (!partial && todayPartialDay >= lookbackStart && todayPartialDay < today) partial = cursor.todayPartial;
|
|
444
|
+
let resume = partial ? Date.parse(partial.day) : Number.isFinite(doneThrough) ? Math.max(lookbackStart, floorUtcDay(doneThrough) + DAY_MS) : lookbackStart;
|
|
445
|
+
if (resume >= today) resume = lookbackStart;
|
|
446
|
+
|
|
447
|
+
// Give today and the rolling previous-day walk a share of the existing
|
|
448
|
+
// budgets. With only one day or call available, alternate them instead
|
|
449
|
+
// of permanently starving either. Missing fields migrate naturally.
|
|
450
|
+
const single = sync.listPagesPerRun === 1 || sync.orderDaysPerRun === 1;
|
|
451
|
+
const readToday = !single || !cursor.historyNext;
|
|
447
452
|
const pageSize = Math.min(ORDER_PAGE_MAX, sync.listCap);
|
|
448
453
|
const byId = new Map();
|
|
449
454
|
const days = [];
|
|
450
455
|
let calls = 0;
|
|
451
456
|
let lastDone = null;
|
|
452
|
-
let stoppedAt =
|
|
453
|
-
|
|
457
|
+
let stoppedAt = partial;
|
|
458
|
+
let todayPartial = todayPartialDay === today ? cursor.todayPartial : partialDay === today ? cursor.partial : null;
|
|
459
|
+
async function readDay(day, progress, callLimit) {
|
|
454
460
|
const label = `order list for ${formatOrderedDay(day)}`;
|
|
455
|
-
let page =
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
break;
|
|
461
|
-
}
|
|
461
|
+
let page = Math.max(1, Number(progress?.page) || 1);
|
|
462
|
+
// Keep at most one page of IDs in the existing cursor, so splitting
|
|
463
|
+
// the budget never disables the guard against an API ignoring page.
|
|
464
|
+
let previous = progress?.previousIds ? new Set(progress.previousIds) : null;
|
|
465
|
+
while (calls < callLimit) {
|
|
462
466
|
calls += 1;
|
|
463
467
|
if (!days.includes(day)) days.push(day);
|
|
464
468
|
const list = listOrFail(await client.get('orders/', { ordered: formatOrderedDay(day), page, pageSize }), label);
|
|
465
469
|
const ids = list.map((item) => text(item?.id)).filter(Boolean);
|
|
466
|
-
// A Seazona that does not page answers the same rows again.
|
|
467
470
|
if (previous && ids.length && ids.every((id) => previous.has(id))) throw cannotGoOn(label, sync, 'the next page answers the same orders');
|
|
468
471
|
for (const item of list) if (text(item?.id)) byId.set(text(item.id), item);
|
|
469
|
-
|
|
472
|
+
// The guide's on-or-after lists and single-day lists both work.
|
|
473
|
+
if (list.length < pageSize || placedAfter(list[list.length - 1], day + DAY_MS)) return null;
|
|
470
474
|
previous = new Set(ids);
|
|
471
475
|
page += 1;
|
|
472
476
|
}
|
|
473
|
-
|
|
477
|
+
return { day: new Date(day).toISOString(), page, previousIds: [...previous] };
|
|
478
|
+
}
|
|
479
|
+
if (readToday) {
|
|
480
|
+
const todayCalls = single ? sync.listPagesPerRun : Math.ceil(sync.listPagesPerRun / 2);
|
|
481
|
+
todayPartial = await readDay(today, todayPartial, todayCalls);
|
|
482
|
+
}
|
|
483
|
+
if (!single || !readToday) {
|
|
484
|
+
for (let day = resume; day < today && days.length < sync.orderDaysPerRun && calls < sync.listPagesPerRun; day += DAY_MS) {
|
|
485
|
+
stoppedAt = await readDay(day, day === resume ? partial : null, sync.listPagesPerRun);
|
|
486
|
+
if (stoppedAt) break;
|
|
487
|
+
lastDone = day;
|
|
488
|
+
}
|
|
474
489
|
}
|
|
475
490
|
const known = await readKnown(db, 'orders', [...byId.keys()]);
|
|
476
491
|
const summary = { created: 0, changed: 0, unchanged: 0 };
|
|
@@ -506,7 +521,9 @@ const laneRuns = {
|
|
|
506
521
|
cursorTime: through === null ? ctx.state?.cursorTime ?? null : new Date(through),
|
|
507
522
|
cursor: {
|
|
508
523
|
...(through === null ? {} : { completedThrough: new Date(through).toISOString() }),
|
|
509
|
-
...(stoppedAt ? { partial:
|
|
524
|
+
...(stoppedAt ? { partial: stoppedAt } : {}),
|
|
525
|
+
...(todayPartial ? { todayPartial } : {}),
|
|
526
|
+
...(single ? { historyNext: readToday } : {}),
|
|
510
527
|
},
|
|
511
528
|
};
|
|
512
529
|
},
|
|
@@ -11,15 +11,16 @@
|
|
|
11
11
|
|
|
12
12
|
export const SEAZONA_SOURCE_SYSTEM = 'seazona';
|
|
13
13
|
|
|
14
|
-
function ledgerLine(line) {
|
|
14
|
+
export function ledgerLine(line) {
|
|
15
15
|
return {
|
|
16
16
|
// The ledger maps a line to a price item by its exact code only.
|
|
17
17
|
itemCode: line.code ?? null,
|
|
18
18
|
description: line.description,
|
|
19
19
|
quantity: 1,
|
|
20
|
-
//
|
|
20
|
+
// A mapped price item can fill unknown amounts or tax.
|
|
21
|
+
// Explicit null tax must not take the legacy GST default.
|
|
21
22
|
unitAmountCents: line.amountCents ?? undefined,
|
|
22
|
-
taxCode: typeof line.taxable === 'boolean' ? (line.taxable ? 'GST' : 'GST_FREE') :
|
|
23
|
+
taxCode: typeof line.taxable === 'boolean' ? (line.taxable ? 'GST' : 'GST_FREE') : null,
|
|
23
24
|
};
|
|
24
25
|
}
|
|
25
26
|
|
|
@@ -37,6 +38,14 @@ export function ledgerLinesFor(item) {
|
|
|
37
38
|
// `observedAt` is the last successful read from Seazona, so the ledger's
|
|
38
39
|
// out-of-date check measures how old Seazona's report really is.
|
|
39
40
|
export function toLedgerJob(item) {
|
|
41
|
+
const { lines } = ledgerLinesFor(item);
|
|
42
|
+
// Totals remain evidence when an invoice has no lines.
|
|
43
|
+
// A shared invoice cannot supply one job's totals. Never split its tax.
|
|
44
|
+
const sourceTotals = item.invoice && item.invoiceOrderCount === 1 ? {
|
|
45
|
+
subtotalCents: item.invoice.subtotalCents ?? null,
|
|
46
|
+
taxCents: item.invoice.taxCents ?? null,
|
|
47
|
+
totalCents: item.invoice.totalCents ?? null,
|
|
48
|
+
} : null;
|
|
40
49
|
return {
|
|
41
50
|
sourceSystem: SEAZONA_SOURCE_SYSTEM,
|
|
42
51
|
sourceJobRef: item.sourceId,
|
|
@@ -44,13 +53,15 @@ export function toLedgerJob(item) {
|
|
|
44
53
|
// The Seazona order id is the case number. Never patient data.
|
|
45
54
|
caseRef: item.sourceId,
|
|
46
55
|
practitioner: item.doctorName ?? null,
|
|
47
|
-
// The Seazona API gives a shipped status but no shipped date
|
|
48
|
-
|
|
56
|
+
// The Seazona API gives a shipped status but no shipped date: the
|
|
57
|
+
// ledger keeps the day it first sees the job shipped.
|
|
58
|
+
shipped: item.shipped === true,
|
|
49
59
|
// The day of the work, for matching it to its accounting invoice.
|
|
50
60
|
jobDate: item.invoice?.invoiceDate ?? item.dueOn ?? item.orderedOn ?? null,
|
|
51
61
|
// The draft rules bill only the statuses the lab chose (for example Shipped).
|
|
52
62
|
sourceStatus: item.status ?? null,
|
|
53
|
-
lines
|
|
63
|
+
lines,
|
|
64
|
+
sourceTotals,
|
|
54
65
|
observedAt: item.syncedAt,
|
|
55
66
|
actor: SEAZONA_SOURCE_SYSTEM,
|
|
56
67
|
};
|
|
@@ -1,4 +1,16 @@
|
|
|
1
|
+
import { and, inArray, isNull } from 'drizzle-orm';
|
|
1
2
|
import { readBillableWork } from './feed.js';
|
|
3
|
+
import { seazonaOrders } from './schema.js';
|
|
4
|
+
|
|
5
|
+
// Presence is not billing eligibility. Keep a job that returned to an open
|
|
6
|
+
// status, lost its lines, or changed its date separate from a missing job.
|
|
7
|
+
export async function readWorkJobPresence(db, { jobRefs }) {
|
|
8
|
+
if (!jobRefs.length) return { presentRefs: [] };
|
|
9
|
+
const rows = await db.select({ ref: seazonaOrders.id }).from(seazonaOrders).where(and(
|
|
10
|
+
inArray(seazonaOrders.id, jobRefs), isNull(seazonaOrders.deletedInSourceAt),
|
|
11
|
+
));
|
|
12
|
+
return { presentRefs: rows.map((row) => row.ref) };
|
|
13
|
+
}
|
|
2
14
|
|
|
3
15
|
// The billable jobs in Flow's copy of Seazona, by day, behind the port's
|
|
4
16
|
// `billableJobs` (the daily reconcile, server/core/reconcile.js). It walks the
|
package/server/rate-limit.js
CHANGED
|
@@ -4,12 +4,13 @@ import expressRateLimit from 'express-rate-limit';
|
|
|
4
4
|
// Express's `trust proxy`). In memory: one Flow process serves one lab. Each
|
|
5
5
|
// call makes its own counter. Built on express-rate-limit, which CodeQL
|
|
6
6
|
// recognises as rate limiting (js/missing-rate-limiting).
|
|
7
|
-
export function rateLimit({ limit, windowMs }) {
|
|
7
|
+
export function rateLimit({ limit, windowMs, ...options }) {
|
|
8
8
|
return expressRateLimit({
|
|
9
9
|
limit,
|
|
10
10
|
windowMs,
|
|
11
11
|
standardHeaders: 'draft-8', // RateLimit-Policy, RateLimit and Retry-After
|
|
12
12
|
legacyHeaders: false,
|
|
13
13
|
handler: (_req, res) => res.status(429).json({ ok: false, code: 'rate_limited', error: 'Too many requests. Wait and try again.' }),
|
|
14
|
+
...options,
|
|
14
15
|
});
|
|
15
16
|
}
|
package/server/ui/app.js
CHANGED
|
@@ -20,6 +20,7 @@ import { renderCustomFields } from '/assets/ui/custom-fields.js';
|
|
|
20
20
|
import { renderJobs } from '/assets/ui/jobs.js';
|
|
21
21
|
import { renderReconcile } from '/assets/ui/reconcile.js';
|
|
22
22
|
import { renderXeroImport } from '/assets/ui/xero-import.js';
|
|
23
|
+
import { syncStatus } from '/assets/ui/sync-status.js';
|
|
23
24
|
import { xeroUsage } from '/assets/ui/xero-usage.js';
|
|
24
25
|
import { startMonitoring } from '/assets/ui/monitoring.js';
|
|
25
26
|
import { isSearchKey, openSearch } from '/assets/ui/search.js';
|
|
@@ -998,74 +999,79 @@ function seazonaHistorySection() {
|
|
|
998
999
|
const canAct = ['owner', 'admin'].includes(me?.person?.role);
|
|
999
1000
|
const yearAgo = new Date(Date.now() - 365 * 86_400_000).toISOString().slice(0, 10);
|
|
1000
1001
|
const from = h('input', { class: 'fx-input fx-history__day', type: 'date', 'aria-label': 'Read from', value: yearAgo, disabled: !canAct });
|
|
1001
|
-
const
|
|
1002
|
-
const
|
|
1003
|
-
const
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1002
|
+
const sync = syncStatus();
|
|
1003
|
+
const fromRow = settingRow({ label: 'Read from', help: 'Reads past Seazona orders into Flow. Orders Flow read before are skipped. Each job waits on hold and never drafts by itself.', control: from });
|
|
1004
|
+
const section = h('section', { class: 'fx-set fx-int-section fx-card fx-history' },
|
|
1005
|
+
h('h2', { class: 'fx-set__title' }, 'History'), fromRow, sync.element);
|
|
1006
|
+
let timer;
|
|
1007
|
+
let pending = false;
|
|
1008
|
+
let run = null;
|
|
1009
|
+
let mounted = false;
|
|
1010
|
+
let disposed = false;
|
|
1011
|
+
let loaded = false;
|
|
1012
|
+
const active = () => ['running', 'waiting'].includes(run?.phase);
|
|
1013
|
+
const schedule = () => {
|
|
1014
|
+
clearTimeout(timer);
|
|
1015
|
+
if (!disposed && !document.hidden && active()) timer = setTimeout(load, 5000);
|
|
1016
|
+
};
|
|
1017
|
+
const press = async (button, label) => {
|
|
1014
1018
|
button.disabled = true;
|
|
1019
|
+
clearTimeout(timer);
|
|
1015
1020
|
try {
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1021
|
+
let resume = run?.resumeFrom;
|
|
1022
|
+
if (label === 'Stop' || label === 'Retry') {
|
|
1023
|
+
const stopped = await flow('/api/seazona/history/backfill/stop', { method: 'POST' });
|
|
1024
|
+
resume = stopped.backfill?.resumeFrom;
|
|
1025
|
+
}
|
|
1026
|
+
if (label !== 'Stop') await flow('/api/seazona/history/backfill', {
|
|
1027
|
+
method: 'POST', body: JSON.stringify(label === 'Resume' || label === 'Retry'
|
|
1028
|
+
? { from: resume ?? run.from, through: run.through } : { from: from.value }),
|
|
1029
|
+
});
|
|
1030
|
+
toast(label === 'Stop' ? 'History read stopped.' : 'History read started.', 'success');
|
|
1031
|
+
} catch (error) { toast(error.message, 'danger'); }
|
|
1032
|
+
loaded = false;
|
|
1033
|
+
await load();
|
|
1023
1034
|
};
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
onclick: (event) => press(event.currentTarget, '/api/seazona/history/backfill', body()),
|
|
1027
|
-
}, label);
|
|
1035
|
+
let actionLabel;
|
|
1036
|
+
const actionButton = h('button', { class: 'fx-btn fx-btn--sm', type: 'button', onclick: event => press(event.currentTarget, actionLabel) });
|
|
1028
1037
|
const load = async () => {
|
|
1029
1038
|
clearTimeout(timer);
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
let body;
|
|
1039
|
+
if (disposed || pending || document.hidden) return;
|
|
1040
|
+
pending = true;
|
|
1033
1041
|
try {
|
|
1034
|
-
body = await flow('/api/seazona/history');
|
|
1042
|
+
const body = await flow('/api/seazona/history');
|
|
1043
|
+
if (disposed) return;
|
|
1044
|
+
loaded = true;
|
|
1045
|
+
run = body.backfill;
|
|
1046
|
+
fromRow.hidden = Boolean(run && run.phase !== 'done');
|
|
1047
|
+
const label = !run ? 'Start' : ({ running: 'Stop', waiting: 'Stop', stopped: run.resumeFrom ? 'Resume' : 'Again', failed: 'Retry', done: 'Again' }[run.phase]);
|
|
1048
|
+
actionLabel = label;
|
|
1049
|
+
actionButton.textContent = label;
|
|
1050
|
+
actionButton.disabled = !canAct;
|
|
1051
|
+
sync.draw({
|
|
1052
|
+
phase: run?.phase ?? 'not-started', startedAt: run?.startedAt, endedAt: run?.endedAt,
|
|
1053
|
+
count: body.history.jobs, unit: 'jobs', from: run?.from, through: run?.through,
|
|
1054
|
+
at: run?.readingDay ?? run?.resumeFrom ?? run?.through,
|
|
1055
|
+
cause: run?.cause, nextRunAt: run?.nextRunAt,
|
|
1056
|
+
}, actionButton);
|
|
1035
1057
|
} catch (error) {
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
}
|
|
1039
|
-
const run = body.backfill;
|
|
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
|
-
}
|
|
1047
|
-
if (run?.running) {
|
|
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}`) : '');
|
|
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'));
|
|
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 })));
|
|
1066
|
-
}
|
|
1067
|
-
timer = setTimeout(() => { if (section.isConnected && run?.running) load(); }, 5_000);
|
|
1058
|
+
sync.draw({ phase: 'failed', cause: 'Could not check progress. Try again.' }, h('button', { class: 'fx-btn fx-btn--sm', type: 'button', onclick: load }, 'Retry'));
|
|
1059
|
+
} finally { pending = false; schedule(); }
|
|
1068
1060
|
};
|
|
1061
|
+
const visible = () => { clearTimeout(timer); if (!document.hidden && (!loaded || active())) load(); };
|
|
1062
|
+
document.addEventListener('visibilitychange', visible);
|
|
1063
|
+
const observer = new MutationObserver(() => {
|
|
1064
|
+
if (section.isConnected) mounted = true;
|
|
1065
|
+
else if (mounted) {
|
|
1066
|
+
disposed = true;
|
|
1067
|
+
clearTimeout(timer);
|
|
1068
|
+
sync.dispose();
|
|
1069
|
+
document.removeEventListener('visibilitychange', visible);
|
|
1070
|
+
observer.disconnect();
|
|
1071
|
+
}
|
|
1072
|
+
});
|
|
1073
|
+
observer.observe(document.body, { childList: true, subtree: true });
|
|
1074
|
+
sync.draw({ phase: 'loading' });
|
|
1069
1075
|
load();
|
|
1070
1076
|
return section;
|
|
1071
1077
|
}
|
|
@@ -1331,11 +1337,17 @@ drawModeToggles();
|
|
|
1331
1337
|
// ── Start ──────────────────────────────────────────────────────────────────
|
|
1332
1338
|
|
|
1333
1339
|
// Clerk's words where they name the app: the installation is Flow, whatever
|
|
1334
|
-
// the Clerk instance is called.
|
|
1340
|
+
// the Clerk instance is called. With sign-up on, Clerk shows the combined
|
|
1341
|
+
// sign-in-or-up form, which reads the *Combined keys.
|
|
1335
1342
|
const LOCALIZATION = {
|
|
1336
1343
|
signIn: {
|
|
1337
1344
|
// titleCombined is the sign-in-or-sign-up screen (public sign-up on).
|
|
1338
|
-
start: {
|
|
1345
|
+
start: {
|
|
1346
|
+
title: 'Sign in to Flow',
|
|
1347
|
+
subtitle: 'Choose how to sign in.',
|
|
1348
|
+
titleCombined: 'Sign in to Flow',
|
|
1349
|
+
subtitleCombined: 'Choose how to sign in.',
|
|
1350
|
+
},
|
|
1339
1351
|
password: { subtitle: 'Enter the password for your account.' },
|
|
1340
1352
|
},
|
|
1341
1353
|
signUp: { start: { title: 'Ask for access to Flow', subtitle: 'Use your work email. An owner approves new people.' } },
|
package/server/ui/flow.css
CHANGED
|
@@ -639,9 +639,13 @@ a.fx-strong:hover { text-decoration: underline; }
|
|
|
639
639
|
.fx-pop__tick { display: inline-grid; place-items: center; flex: none; width: 16px; height: 16px; color: var(--t-accent); }
|
|
640
640
|
.fx-pop__sep { height: 1px; margin: 6px 4px; background: var(--fx-line-soft); }
|
|
641
641
|
/* A row with a trailing control (the sidebar pin). The control does not
|
|
642
|
-
close the menu. An unpinned pin shows when the pointer is on the row.
|
|
643
|
-
|
|
642
|
+
close the menu. An unpinned pin shows when the pointer is on the row.
|
|
643
|
+
The whole row lights on hover; the pin under the pointer goes back to the
|
|
644
|
+
menu's own colour, so both read. */
|
|
645
|
+
.fx-pop__row { display: flex; align-items: center; gap: 2px; padding-right: 4px; border-radius: var(--t-radius); }
|
|
646
|
+
.fx-pop__row:hover, .fx-pop__row:has(:focus-visible) { background: var(--fx-hover); }
|
|
644
647
|
.fx-pop__row > .fx-pop__item { flex: 1; min-width: 0; }
|
|
648
|
+
.fx-pop__row > .fx-pop__item:is(:hover, :focus-visible) { background: none; }
|
|
645
649
|
.fx-pop__row > .fx-pop__item > span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
646
650
|
.fx-pop__pin, .fx-pop__pin--fixed {
|
|
647
651
|
display: inline-grid; place-items: center; flex: none; width: 28px; height: 28px;
|
|
@@ -654,13 +658,14 @@ a.fx-strong:hover { text-decoration: underline; }
|
|
|
654
658
|
.fx-pop__row:focus-within .fx-pop__pin[aria-pressed="false"],
|
|
655
659
|
.fx-pop__pin:focus-visible { opacity: 1; }
|
|
656
660
|
.fx-pop__pin[aria-pressed="true"], .fx-pop__pin--fixed { color: var(--t-strong); opacity: 1; }
|
|
657
|
-
.fx-pop__pin:hover, .fx-pop__pin:focus-visible { background: var(--
|
|
661
|
+
.fx-pop__pin:hover, .fx-pop__pin:focus-visible { background: var(--t-paper); color: var(--t-strong); outline: none; }
|
|
658
662
|
.fx-pop.fx-pop--side { min-width: 240px; }
|
|
659
663
|
.fx-pop--side .fx-pop__tick { color: inherit; }
|
|
660
664
|
@media (hover: none) { .fx-pop__pin[aria-pressed="false"] { opacity: 1; } }
|
|
661
665
|
@media (max-width: 820px) {
|
|
662
666
|
.fx-pop--side .fx-pop__item { min-height: 44px; }
|
|
663
667
|
.fx-pop--side .fx-pop__pin, .fx-pop--side .fx-pop__pin--fixed { width: 44px; height: 44px; }
|
|
668
|
+
.fx-pop--side .fx-pop__row { padding-right: 0; }
|
|
664
669
|
}
|
|
665
670
|
.fx-pop__foot { display: flex; justify-content: flex-end; padding: 6px 4px 2px; border-top: 1px solid var(--fx-line-soft); margin-top: 6px; }
|
|
666
671
|
/* A chip with an × that clears it (as ClickUp's "Clear all filters"). */
|
|
@@ -1287,9 +1292,32 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
|
|
|
1287
1292
|
.fx-setting__head { display: flex; align-items: center; gap: 2px; min-width: 0; }
|
|
1288
1293
|
.fx-setting__head .fx-help { margin: -4px 0; border: 0; background: none; cursor: pointer; }
|
|
1289
1294
|
.fx-setting__hint { margin: 0; color: var(--t-dim); font-size: 13px; line-height: 1.45; }
|
|
1290
|
-
.fx-history__status { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
|
1291
1295
|
.fx-history__day { width: 160px; flex: none; }
|
|
1292
|
-
|
|
1296
|
+
/* SyncStatus: Layers, shared by integration history readers. */
|
|
1297
|
+
.fx-history { padding: 8px 20px 16px; }
|
|
1298
|
+
.fx-sync { min-width: 0; padding-block: 8px; }
|
|
1299
|
+
.fx-sync__line { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; }
|
|
1300
|
+
.fx-sync__status { min-width: 0; overflow-wrap: anywhere; color: var(--t-text); font-size: 13px; }
|
|
1301
|
+
.fx-sync__word { position: relative; display: inline-block; overflow: hidden; vertical-align: bottom; }
|
|
1302
|
+
.fx-sync[data-phase="running"] .fx-sync__word::after { content: ''; position: absolute; inset: 0; background: linear-gradient(90deg, transparent, var(--t-paper), transparent); opacity: .5; transform: translateX(-100%); animation: fx-sync-sweep 2.6s linear infinite; }
|
|
1303
|
+
.fx-sync[data-phase="failed"] .fx-sync__word { color: var(--t-danger); }
|
|
1304
|
+
.fx-sync[data-phase="done"] .fx-sync__word { color: var(--t-success); }
|
|
1305
|
+
.fx-sync__clock { font: 12px var(--t-data); color: var(--t-dim); }
|
|
1306
|
+
.fx-sync__action { margin-left: auto; }
|
|
1307
|
+
.fx-sync__action .fx-btn { min-width: 44px; min-height: 44px; }
|
|
1308
|
+
.fx-sync__dots { display: inline-flex; gap: 3px; margin-right: 8px; vertical-align: middle; }
|
|
1309
|
+
.fx-sync__dots[hidden], .fx-sync__track[hidden] { display: none; }
|
|
1310
|
+
.fx-sync__dots i { width: 4px; height: 4px; border-radius: var(--t-radius); background: var(--t-accent); animation: fx-sync-breathe 1.8s ease-in-out infinite; }
|
|
1311
|
+
.fx-sync__dots i:nth-child(2) { animation-delay: .2s; }
|
|
1312
|
+
.fx-sync__dots i:nth-child(3) { animation-delay: .4s; }
|
|
1313
|
+
.fx-sync__track { display: grid; gap: 4px; margin-top: 8px; }
|
|
1314
|
+
.fx-sync__rail { width: 100%; height: 4px; accent-color: var(--t-brand); }
|
|
1315
|
+
.fx-sync__range { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 4px 12px; color: var(--t-dim); font-size: 12px; }
|
|
1316
|
+
@media (max-width: 639px) {
|
|
1317
|
+
.fx-sync__line { gap: 4px 8px; }
|
|
1318
|
+
.fx-sync__status { flex: 1 1 60%; }
|
|
1319
|
+
.fx-sync__clock { order: 1; flex-basis: 100%; }
|
|
1320
|
+
}
|
|
1293
1321
|
.fx-setting[data-tone="danger"] .fx-setting__hint { color: var(--t-danger); }
|
|
1294
1322
|
.fx-setting__control { display: flex; align-items: center; justify-content: flex-end; gap: 10px; min-width: 0; }
|
|
1295
1323
|
.fx-setting__control > .fx-input, .fx-setting__control > .fx-select, .fx-setting__stack { width: 320px; max-width: 100%; }
|
|
@@ -1539,9 +1567,12 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
|
|
|
1539
1567
|
}
|
|
1540
1568
|
|
|
1541
1569
|
@keyframes fx-rise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
|
|
1570
|
+
@keyframes fx-sync-breathe { 50% { opacity: .35; transform: scale(.75); } }
|
|
1571
|
+
@keyframes fx-sync-sweep { to { transform: translateX(100%); } }
|
|
1542
1572
|
@keyframes fx-spin { to { transform: rotate(360deg); } }
|
|
1543
1573
|
@keyframes fx-shimmer { to { background-position: -200% 0; } }
|
|
1544
1574
|
@media (prefers-reduced-motion: reduce) {
|
|
1575
|
+
.fx-sync__word::after { display: none; }
|
|
1545
1576
|
*, *::before, *::after { animation: none !important; transition: none !important; }
|
|
1546
1577
|
}
|
|
1547
1578
|
|