@arcforgelabs/flow 2026.10.53 → 2026.11.2

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 (45) hide show
  1. package/README.md +1 -0
  2. package/SECURITY.md +7 -4
  3. package/npm-shrinkwrap.json +442 -4
  4. package/package.json +8 -3
  5. package/server/agents/auth-worker.js +7 -0
  6. package/server/agents/auth.js +50 -0
  7. package/server/agents/gate.js +138 -0
  8. package/server/agents/grants.js +51 -0
  9. package/server/agents/reads.js +47 -0
  10. package/server/agents/server.js +74 -0
  11. package/server/agents/settings.js +24 -0
  12. package/server/api/invoicing.js +27 -10
  13. package/server/app.js +9 -0
  14. package/server/core/invoice-rules.js +25 -10
  15. package/server/core/job-board.js +12 -4
  16. package/server/core/ledger.js +110 -22
  17. package/server/core/line-mapping.js +9 -5
  18. package/server/core/read-before-act.js +2 -2
  19. package/server/core/reconcile.js +15 -1
  20. package/server/core/sending.js +33 -9
  21. package/server/db/migrations/0025_powerful_leopardon.sql +1 -0
  22. package/server/db/migrations/0026_bitter_sersi.sql +38 -0
  23. package/server/db/migrations/meta/0025_snapshot.json +4709 -0
  24. package/server/db/migrations/meta/0026_snapshot.json +4926 -0
  25. package/server/db/migrations/meta/_journal.json +14 -0
  26. package/server/db/schema.js +38 -1
  27. package/server/index.js +9 -1
  28. package/server/integrations/port.js +1 -0
  29. package/server/integrations/seazona/history/backfill.js +3 -1
  30. package/server/integrations/seazona/history/index.js +4 -7
  31. package/server/integrations/seazona/index.js +5 -1
  32. package/server/integrations/seazona/lanes.js +44 -27
  33. package/server/integrations/seazona/ledger.js +17 -6
  34. package/server/integrations/seazona/reconcile.js +12 -0
  35. package/server/rate-limit.js +2 -1
  36. package/server/ui/app.js +76 -64
  37. package/server/ui/flow.css +43 -8
  38. package/server/ui/invoice-rules.js +24 -3
  39. package/server/ui/invoicing.js +76 -13
  40. package/server/ui/job-board.js +12 -7
  41. package/server/ui/job-change.js +63 -15
  42. package/server/ui/jobs.js +4 -4
  43. package/server/ui/mobile.js +17 -0
  44. package/server/ui/routes.js +1 -1
  45. 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
  }
@@ -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 } while failed.
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((l) => ({
521
- itemCode: l.code ?? null,
522
- description: l.description,
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 nowMs = at.getTime();
434
- const lookbackStart = floorUtcDay(nowMs - sync.orderLookbackDays * DAY_MS);
435
- const doneThrough = Date.parse(ctx.state?.cursor?.completedThrough ?? '');
436
- const partial = ctx.state?.cursor?.partial;
437
- const partialDay = Date.parse(partial?.day ?? '');
438
- // Go on inside a day a run did not finish; otherwise re-read the last
439
- // finished day as well, so late orders on it are seen.
440
- const resumeInside = Number.isFinite(partialDay) && partialDay >= lookbackStart;
441
- const resume = resumeInside ? partialDay : Number.isFinite(doneThrough) ? Math.max(lookbackStart, doneThrough) : lookbackStart;
442
-
443
- // Issue #125: the list is paged (page, pageSize). A full page goes on to
444
- // the next page; a short page, or a page past the day, ends the day. The
445
- // guide says the list answers orders on or after `ordered`; a list that
446
- // answers one day works the same way.
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 = null;
453
- for (let day = floorUtcDay(resume); day <= floorUtcDay(nowMs) && days.length < sync.orderDaysPerRun && !stoppedAt; day += DAY_MS) {
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 = resumeInside && day === floorUtcDay(resume) ? Math.max(1, Number(partial.page) || 1) : 1;
456
- let previous = null;
457
- for (;;) {
458
- if (calls >= sync.listPagesPerRun) {
459
- stoppedAt = { day, page };
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
- if (list.length < pageSize || placedAfter(list[list.length - 1], day + DAY_MS)) break;
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
- if (!stoppedAt) lastDone = day;
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: { day: new Date(stoppedAt.day).toISOString(), page: stoppedAt.page } } : {}),
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
- // Unknown amount or tax: the price item with the same code fills them.
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') : undefined,
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
- shippedOn: null,
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: ledgerLinesFor(item).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
@@ -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,13 +20,14 @@ 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';
26
27
  import { alertStrip } from '/assets/ui/alerts.js';
27
28
  import { initialsOf, personLabel } from '/assets/ui/person.js';
28
29
  import { startTips } from '/assets/ui/tip.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';
30
+ import { cardRows, crumbs, dialog, edgeFade, fieldError, h, holdButton, iconPicker, linksOpenWith, listFrame, listHead, notice, pageHeader, pickOne, rowMenu, sectionTitle, settingRow, tileCard, tileSection, toast } from '/assets/ui/kit.js';
30
31
  import { NAV_DEFAULT, NAV_LOCKED } from '/assets/ui/nav-pins.js';
31
32
 
32
33
  const $ = (id) => document.getElementById(id);
@@ -335,7 +336,7 @@ function navigate(event) {
335
336
  // The shared pieces a view module (invoicing.js) builds with. Its `go`
336
337
  // takes a path, or a link's click event.
337
338
  const ui = () => ({
338
- h, flow, toast, notice, fieldError, pageHeader, listHead, listFrame, edgeFade, sectionTitle, settingRow, dialog, pickOne, iconPicker, me, holdButton, tileCard, tileSection, crumbs,
339
+ h, flow, toast, notice, cardRows, fieldError, pageHeader, listHead, listFrame, edgeFade, sectionTitle, settingRow, dialog, pickOne, iconPicker, me, holdButton, tileCard, tileSection, crumbs,
339
340
  go: (target, options) => (typeof target === 'string' ? go(target, options) : navigate(target)),
340
341
  });
341
342
 
@@ -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 status = h('span', { class: 'fx-history__status' }, 'Checking…');
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;
1006
- const day = (value) => new Date(`${value}T00:00:00Z`).toLocaleDateString('en-AU', { dateStyle: 'medium', timeZone: 'UTC' });
1007
- const section = h('section', { class: 'fx-set fx-int-section fx-card', style: { padding: '8px 20px 16px' } },
1008
- h('h2', { class: 'fx-set__title' }, 'History'),
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;
1013
- const press = async (button, path, body) => {
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
- await flow(path, { method: 'POST', ...(body ? { body: JSON.stringify(body) } : {}) });
1017
- toast(body ? 'History read started.' : 'History read stopped.', 'success');
1018
- } catch (error) {
1019
- toast(error.message, 'danger');
1020
- if (error.retryAfterMs) holdButton(button, error.retryAfterMs);
1021
- }
1022
- load();
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
- 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);
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
- clearInterval(tick);
1031
- if (!section.isConnected && timer !== null) return;
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
- status.replaceChildren(error.message);
1037
- return;
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: { title: 'Sign in', titleCombined: 'Sign in', subtitle: '' },
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.' } },