@arcforgelabs/flow 2026.10.27 → 2026.10.30

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 (60) hide show
  1. package/README.md +1 -0
  2. package/npm-shrinkwrap.json +2 -2
  3. package/package.json +2 -2
  4. package/server/api/invoicing.js +96 -40
  5. package/server/app.js +3 -1
  6. package/server/core/automations.js +46 -1
  7. package/server/core/invoice-rules.js +4 -2
  8. package/server/core/invoices.js +4 -2
  9. package/server/core/job-board.js +130 -0
  10. package/server/core/ledger.js +12 -5
  11. package/server/core/line-mapping.js +337 -0
  12. package/server/core/pipeline.js +18 -6
  13. package/server/core/queue.js +22 -0
  14. package/server/core/sending.js +20 -7
  15. package/server/db/migrate-all.js +26 -0
  16. package/server/db/migrations/0010_invoicing_rules.sql +34 -13
  17. package/server/db/migrations/0011_invoice_rules.sql +11 -5
  18. package/server/db/migrations/0012_invoice_sending.sql +18 -9
  19. package/server/db/migrations/0013_line_mappings.sql +35 -0
  20. package/server/db/migrations/meta/0013_snapshot.json +3568 -0
  21. package/server/db/migrations/meta/_journal.json +7 -0
  22. package/server/db/schema.js +43 -0
  23. package/server/import/at-start.js +64 -2
  24. package/server/index.js +5 -1
  25. package/server/integrations/port.js +4 -1
  26. package/server/integrations/seazona/client.js +6 -0
  27. package/server/integrations/seazona/feed.js +62 -4
  28. package/server/integrations/seazona/governor.js +138 -13
  29. package/server/integrations/seazona/index.js +21 -2
  30. package/server/integrations/seazona/lanes.js +69 -13
  31. package/server/integrations/seazona/migrations/0000_api_governor.sql +3 -3
  32. package/server/integrations/seazona/migrations/0001_mirror.sql +9 -9
  33. package/server/integrations/seazona/migrations/0002_call_safety.sql +15 -0
  34. package/server/integrations/seazona/migrations/meta/0002_snapshot.json +882 -0
  35. package/server/integrations/seazona/migrations/meta/_journal.json +7 -0
  36. package/server/integrations/seazona/scheduler.js +37 -1
  37. package/server/integrations/seazona/schema.js +22 -0
  38. package/server/integrations/seazona/settings.js +10 -0
  39. package/server/integrations/seazona/store.js +74 -0
  40. package/server/integrations/seazona/tables.js +1 -0
  41. package/server/integrations/xero/drafts.js +4 -1
  42. package/server/integrations/xero/import.js +1 -0
  43. package/server/integrations/xero/lookup.js +6 -2
  44. package/server/integrations/xero/migrations/0000_xero_connections.sql +1 -1
  45. package/server/integrations/xero/migrations/0001_xero_catalogue.sql +5 -5
  46. package/server/integrations/xero/migrations/0002_xero_invoice_pushes.sql +2 -2
  47. package/server/integrations/xero/migrations/0003_xero_payment_cursors.sql +1 -1
  48. package/server/integrations/xero/migrations/0004_xero_mirror.sql +7 -7
  49. package/server/integrations/xero/migrations/0005_xero_send_modes.sql +12 -6
  50. package/server/ui/app-page.js +6 -4
  51. package/server/ui/app.js +111 -8
  52. package/server/ui/clerk-appearance.js +21 -0
  53. package/server/ui/flow.css +155 -30
  54. package/server/ui/invoicing-settings.js +2 -0
  55. package/server/ui/job-board.js +155 -0
  56. package/server/ui/jobs.js +220 -91
  57. package/server/ui/line-mappings.js +60 -0
  58. package/server/ui/monitoring.js +29 -7
  59. package/server/ui/routes.js +1 -1
  60. package/server/ui/search.js +145 -0
@@ -15,6 +15,13 @@
15
15
  "when": 1792330268730,
16
16
  "tag": "0001_mirror",
17
17
  "breakpoints": true
18
+ },
19
+ {
20
+ "idx": 2,
21
+ "version": "7",
22
+ "when": 1792448378477,
23
+ "tag": "0002_call_safety",
24
+ "breakpoints": true
18
25
  }
19
26
  ]
20
27
  }
@@ -4,12 +4,20 @@ import { readLaneStates } from './store.js';
4
4
  // The background sync, for live mode only (issue #10: index.js never creates
5
5
  // it in sandbox or replay). It runs the lanes that are due, then sleeps until
6
6
  // the next lane is due, between 15 seconds and 30 minutes. One run at a time.
7
+ // A stopped lane (issue #101) is not due until a person or a release resets it.
8
+ //
9
+ // Issue #101: each held failure is reported once to the error services
10
+ // (log.error, server/core/monitoring.js): when a lane starts failing or its
11
+ // failure code changes, and when records are quarantined. A repeat of the
12
+ // same failure is a warning in the host log only. The report text is stable,
13
+ // so the error service groups repeats.
7
14
 
8
15
  const MIN_DELAY_MS = 15_000;
9
16
  const MAX_DELAY_MS = 30 * 60_000;
10
17
 
11
18
  export function createSeazonaScheduler({
12
19
  db,
20
+ settings = null,
13
21
  run,
14
22
  now = Date.now,
15
23
  log = console,
@@ -21,11 +29,39 @@ export function createSeazonaScheduler({
21
29
  let stopped = false;
22
30
  let nextRunAt = null;
23
31
  let last = null;
32
+ // lane -> the failure code last reported, while that lane keeps failing.
33
+ const reported = new Map();
34
+ const quarantineAt = settings?.sync?.orderQuarantineFailures ?? null;
35
+
36
+ function newlyQuarantined(outcome) {
37
+ if (Number.isInteger(outcome.newlyQuarantined)) return outcome.newlyQuarantined;
38
+ const failures = outcome.failureDetails?.failures ?? [];
39
+ return failures.filter((f) => f.quarantinedUntil && f.failureCount === quarantineAt).length;
40
+ }
41
+
42
+ function report(results) {
43
+ for (const [lane, outcome] of Object.entries(results)) {
44
+ if (outcome.ok) {
45
+ reported.delete(lane);
46
+ const count = newlyQuarantined(outcome);
47
+ if (count) log.error?.(`[seazona] ${lane}: records quarantined after repeated failed reads`);
48
+ continue;
49
+ }
50
+ if (reported.get(lane) === outcome.code) {
51
+ log.warn?.(`[seazona] ${lane}: ${outcome.error}`);
52
+ continue;
53
+ }
54
+ reported.set(lane, outcome.code);
55
+ log.error?.(`[seazona] ${lane} lane ${outcome.status === 'stopped' ? 'stopped' : 'failed'}: ${outcome.code}`);
56
+ log.warn?.(`[seazona] ${lane}: ${outcome.error}`);
57
+ }
58
+ }
24
59
 
25
60
  async function nextDelayMs() {
26
61
  const states = await readLaneStates(db);
27
62
  let soonest = Infinity;
28
63
  for (const lane of LANES) {
64
+ if (states.get(lane)?.status === 'stopped') continue;
29
65
  const at = states.get(lane)?.nextRunAt?.getTime();
30
66
  soonest = Math.min(soonest, Number.isFinite(at) ? at - Number(now()) : 0);
31
67
  }
@@ -48,7 +84,7 @@ export function createSeazonaScheduler({
48
84
  const result = await run();
49
85
  const failed = Object.entries(result.results).filter(([, lane]) => !lane.ok);
50
86
  last = { at: startedAt, ok: result.ok, lanes: Object.keys(result.results), failed: failed.map(([lane]) => lane) };
51
- for (const [lane, outcome] of failed) log.warn?.(`[seazona] ${lane}: ${outcome.error}`);
87
+ report(result.results);
52
88
  } catch (error) {
53
89
  last = { at: startedAt, ok: false, error: error?.message || String(error) };
54
90
  log.error?.(`[seazona] sync run failed: ${last.error}`);
@@ -10,10 +10,17 @@ const at = (name) => timestamp(name, { withTimezone: true });
10
10
 
11
11
  // One row per API client: when the next request may go, and any cooldown a
12
12
  // 429 asked for. Every process that shares the database shares this row.
13
+ // Issue #101: the circuit breaker (open until, and how many times in a row it
14
+ // opened) and a stop that waits for a person (`halted_reason`, for example
15
+ // `auth` after a 401).
13
16
  export const seazonaApiGovernor = pgTable('seazona_api_governor', {
14
17
  clientKey: text('client_key').primaryKey(),
15
18
  nextEligibleAt: at('next_eligible_at').notNull().default(sql`'epoch'`),
16
19
  cooldownUntil: at('cooldown_until').notNull().default(sql`'epoch'`),
20
+ circuitOpenUntil: at('circuit_open_until').notNull().default(sql`'epoch'`),
21
+ circuitOpens: integer('circuit_opens').notNull().default(0),
22
+ haltedReason: text('halted_reason'),
23
+ haltedAt: at('halted_at'),
17
24
  updatedAt: at('updated_at').notNull().defaultNow(),
18
25
  });
19
26
 
@@ -155,3 +162,18 @@ export const seazonaOrderReadFailures = pgTable('seazona_order_read_failures', {
155
162
  lastError: text('last_error'),
156
163
  quarantinedUntil: at('quarantined_until'),
157
164
  });
165
+
166
+ // Issue #101: each failed detail read of an invoice or a client, per record.
167
+ // Orders keep their own table (issue #12). After a few failures in a row the
168
+ // record is quarantined: the lane skips it and its cursor moves on.
169
+ export const seazonaRecordReadFailures = pgTable('seazona_record_read_failures', {
170
+ resource: text('resource').notNull(),
171
+ recordKey: text('record_key').notNull(),
172
+ failureCount: integer('failure_count').notNull(),
173
+ lastAttemptAt: at('last_attempt_at').notNull(),
174
+ lastHttpStatus: integer('last_http_status'),
175
+ lastFailureKind: text('last_failure_kind'),
176
+ quarantinedUntil: at('quarantined_until'),
177
+ }, (t) => [
178
+ primaryKey({ columns: [t.resource, t.recordKey] }),
179
+ ]);
@@ -75,6 +75,16 @@ export function resolveSeazonaSettings(env = process.env) {
75
75
  maxWaitMs: clampInt(env.SEAZONA_API_MAX_WAIT_MS, 120_000, 1_000, 15 * 60_000),
76
76
  // Long enough to answer a vendor's question about last month.
77
77
  telemetryRetentionMs: clampInt(env.SEAZONA_API_TELEMETRY_RETENTION_DAYS, 35, 1, 90) * DAY_MS,
78
+ // Issue #101: a vendor can cut off API access after many failed calls,
79
+ // even inside the rate limit. This many failed calls inside the window
80
+ // open the circuit: no call goes out for the pause, which doubles each
81
+ // time the probe after it fails, to at most 6 hours.
82
+ circuitFailures: clampInt(env.SEAZONA_CIRCUIT_FAILURES, 5, 1, 100),
83
+ circuitWindowMs: clampInt(env.SEAZONA_CIRCUIT_WINDOW_MS, 10 * 60_000, 60_000, DAY_MS),
84
+ circuitPauseMs: clampInt(env.SEAZONA_CIRCUIT_PAUSE_MS, 5 * 60_000, 60_000, 6 * 60 * 60_000),
85
+ // Failed calls allowed in one UTC day. When they are spent, calls stop
86
+ // until the next day.
87
+ dailyFailureBudget: clampInt(env.SEAZONA_DAILY_FAILURE_BUDGET, 50, 1, 10_000),
78
88
  };
79
89
 
80
90
  const MINUTE = 60_000;
@@ -6,6 +6,7 @@ import {
6
6
  seazonaLaneState,
7
7
  seazonaOrderReadFailures,
8
8
  seazonaOrders,
9
+ seazonaRecordReadFailures,
9
10
  seazonaReference,
10
11
  } from './schema.js';
11
12
 
@@ -192,6 +193,60 @@ export async function readQuarantinedOrders(db, { at, limit = 25 }) {
192
193
  }));
193
194
  }
194
195
 
196
+ // Issue #101: failed detail reads of invoices and clients, per record.
197
+ // Answers Map(key -> row) for the keys that are quarantined at `at`.
198
+ export async function readQuarantinedRecords(db, resource, keys, { at }) {
199
+ if (!keys.length) return new Map();
200
+ const rows = await db.select().from(seazonaRecordReadFailures).where(and(
201
+ eq(seazonaRecordReadFailures.resource, resource),
202
+ inArray(seazonaRecordReadFailures.recordKey, keys),
203
+ gt(seazonaRecordReadFailures.quarantinedUntil, at),
204
+ ));
205
+ return new Map(rows.map((row) => [row.recordKey, row]));
206
+ }
207
+
208
+ export async function clearRecordFailures(db, resource, keys) {
209
+ if (!keys.length) return;
210
+ await db.delete(seazonaRecordReadFailures).where(and(
211
+ eq(seazonaRecordReadFailures.resource, resource),
212
+ inArray(seazonaRecordReadFailures.recordKey, keys),
213
+ ));
214
+ }
215
+
216
+ // Records one failed read, as recordOrderFailure does for orders. Answers
217
+ // { failureCount, quarantinedUntil }.
218
+ export async function recordRecordFailure(db, resource, key, failure, { at, quarantineFailures, quarantineMs }) {
219
+ const until = new Date(at.getTime() + quarantineMs);
220
+ const [row] = await db.insert(seazonaRecordReadFailures).values({
221
+ resource,
222
+ recordKey: key,
223
+ failureCount: 1,
224
+ lastAttemptAt: at,
225
+ lastHttpStatus: failure.httpStatus,
226
+ lastFailureKind: failure.kind,
227
+ quarantinedUntil: quarantineFailures <= 1 ? until : null,
228
+ }).onConflictDoUpdate({
229
+ target: [seazonaRecordReadFailures.resource, seazonaRecordReadFailures.recordKey],
230
+ set: {
231
+ failureCount: sql`${seazonaRecordReadFailures.failureCount} + 1`,
232
+ lastAttemptAt: at,
233
+ lastHttpStatus: failure.httpStatus,
234
+ lastFailureKind: failure.kind,
235
+ quarantinedUntil: sql`case when ${seazonaRecordReadFailures.failureCount} + 1 >= ${quarantineFailures}
236
+ then ${until}::timestamptz else null end`,
237
+ },
238
+ }).returning();
239
+ return { failureCount: row.failureCount, quarantinedUntil: row.quarantinedUntil?.toISOString() ?? null };
240
+ }
241
+
242
+ export async function readQuarantinedRecordCounts(db, { at }) {
243
+ const rows = await db.select({ resource: seazonaRecordReadFailures.resource, records: sql`count(*)::int` })
244
+ .from(seazonaRecordReadFailures)
245
+ .where(gt(seazonaRecordReadFailures.quarantinedUntil, at))
246
+ .groupBy(seazonaRecordReadFailures.resource);
247
+ return Object.fromEntries(rows.map((row) => [row.resource, row.records]));
248
+ }
249
+
195
250
  // Lane state.
196
251
 
197
252
  export async function readLaneStates(db) {
@@ -214,6 +269,25 @@ export async function claimLane(db, lane, { at, stuckMs }) {
214
269
  return claimed.length > 0;
215
270
  }
216
271
 
272
+ // Issue #101: a lane stopped for a person or a release runs again. Answers
273
+ // the lanes it reset.
274
+ export async function resetStoppedLanes(db, { at }) {
275
+ const rows = await db.update(seazonaLaneState)
276
+ .set({ status: 'idle', nextRunAt: at, failureCount: 0, heldReason: null, updatedAt: at })
277
+ .where(eq(seazonaLaneState.status, 'stopped'))
278
+ .returning({ lane: seazonaLaneState.lane });
279
+ return rows.map((row) => row.lane);
280
+ }
281
+
282
+ // A person acted: every lane that failed or stopped runs at the next tick.
283
+ export async function resetFailedLanes(db, { at }) {
284
+ const rows = await db.update(seazonaLaneState)
285
+ .set({ status: 'idle', nextRunAt: at, failureCount: 0, heldReason: null, updatedAt: at })
286
+ .where(inArray(seazonaLaneState.status, ['stopped', 'failed', 'held']))
287
+ .returning({ lane: seazonaLaneState.lane });
288
+ return rows.map((row) => row.lane);
289
+ }
290
+
217
291
  export async function finishLane(db, lane, patch) {
218
292
  await db.update(seazonaLaneState).set({ ...patch, updatedAt: patch.lastAttemptAt ?? new Date() }).where(eq(seazonaLaneState.lane, lane));
219
293
  }
@@ -13,5 +13,6 @@ export const seazonaSchema = Object.freeze({
13
13
  'seazona_reference',
14
14
  'seazona_lane_state',
15
15
  'seazona_order_read_failures',
16
+ 'seazona_record_read_failures',
16
17
  ]),
17
18
  });
@@ -94,7 +94,7 @@ async function documentFor(db, organisationId, invoiceId) {
94
94
  currency: invoice.currency,
95
95
  contact: { id: contact.id, name: contact.name, email: contact.email },
96
96
  lines: invoice.lines.map((l) => ({
97
- position: l.position, itemCode: codes.get(l.priceItemId) ?? null, description: l.description, quantity: l.quantity,
97
+ position: l.position, itemCode: codes.get(l.priceItemId) ?? null, ...(l.accountCode ? { accountCode: l.accountCode } : {}), description: l.description, quantity: l.quantity,
98
98
  unitAmountCents: l.unitAmountCents, taxCode: l.taxCode, lineAmountCents: l.lineAmountCents, taxCents: l.taxCents,
99
99
  })),
100
100
  subtotalCents: invoice.subtotalCents,
@@ -131,6 +131,9 @@ export function buildDraft(document, { settings, contact, items, sendMode = 'acc
131
131
  if (line.itemCode) {
132
132
  if (items.has(line.itemCode)) out.ItemCode = line.itemCode;
133
133
  else problems.push(problem('xero_item_missing', `Line ${line.position}: item ${line.itemCode} is not in Xero. Add it in Xero, then sync.`, { itemCode: line.itemCode }));
134
+ } else if (line.accountCode) {
135
+ // A line with no item books to its own account, such as a surcharge.
136
+ out.AccountCode = line.accountCode;
134
137
  }
135
138
  if (tracking) out.Tracking = [tracking];
136
139
  return out;
@@ -109,6 +109,7 @@ export function createImportWorker({ db, runtime, log = console, timers = { setT
109
109
  if (ran) {
110
110
  busy = true;
111
111
  const read = (ran.job.result?.results ?? []).map((r) => `${r.resource} ${r.read}`).join(', ');
112
+ if (ran.outcome === 'dead') log.error?.('[xero] the history import gave up after its last attempt');
112
113
  log.info?.(`[xero] import ${ran.outcome} for organisation ${ran.job.organisationId}${read ? `: ${read}` : ''}${ran.job.result?.more ? `; more (${ran.job.result.more})` : ''}`);
113
114
  }
114
115
  } catch (error) {
@@ -1,6 +1,5 @@
1
1
  import { sql } from 'drizzle-orm';
2
2
  import { ledgerState } from './ledger.js';
3
- import { MIRROR_RESOURCES } from './mirror.js';
4
3
 
5
4
  // What the core's matcher (server/core/pipeline.js) reads from Xero: the
6
5
  // customers and the sales invoices in Flow's own copy. It never calls Xero, so
@@ -10,6 +9,11 @@ import { MIRROR_RESOURCES } from './mirror.js';
10
9
  // stops counting as proof.
11
10
  export const MIRROR_FRESH_MS = 24 * 60 * 60 * 1000;
12
11
 
12
+ // The copies that prove whether Xero holds an invoice for a job. Payments do
13
+ // not: a connection without a payments scope (the default) never copies them,
14
+ // and must still match jobs.
15
+ export const PROOF_RESOURCES = Object.freeze(['invoices', 'credit_notes']);
16
+
13
17
  export async function listCustomers(db, organisationId) {
14
18
  const { rows } = await db.execute(sql`
15
19
  select xero_contact_id, name, status from xero_contacts
@@ -26,7 +30,7 @@ export async function mirrorState(db, organisationId, { now = new Date() } = {})
26
30
  where organisation_id = ${organisationId}`);
27
31
  const byResource = new Map(rows.map((r) => [r.resource, r]));
28
32
  const invoices = byResource.get('invoices');
29
- const finished = MIRROR_RESOURCES.every((r) => (byResource.get(r)?.passes ?? 0) > 0);
33
+ const finished = PROOF_RESOURCES.every((r) => (byResource.get(r)?.passes ?? 0) > 0);
30
34
  const lastPass = invoices?.last_pass_finished_at ? new Date(invoices.last_pass_finished_at) : null;
31
35
  const { rows: [changed] } = await db.execute(sql`
32
36
  select max(last_changed_at) as at from xero_invoices where organisation_id = ${organisationId}`);
@@ -1,6 +1,6 @@
1
1
  -- The Xero connection of each Flow organisation. The refresh token is
2
2
  -- AES-256-GCM ciphertext (XERO_TOKEN_KEY); the plain token is never stored.
3
- CREATE TABLE "xero_connections" (
3
+ CREATE TABLE IF NOT EXISTS "xero_connections" (
4
4
  "organisation_id" uuid PRIMARY KEY NOT NULL REFERENCES "organisations"("id"),
5
5
  "mode" text NOT NULL,
6
6
  "tenant_id" text NOT NULL,
@@ -1,6 +1,6 @@
1
1
  -- Flow's copy of the Xero contacts and items of each connected organisation,
2
2
  -- and a record of each sync. Xero owns these records; a sync replaces the copy.
3
- CREATE TABLE "xero_contacts" (
3
+ CREATE TABLE IF NOT EXISTS "xero_contacts" (
4
4
  "organisation_id" uuid NOT NULL REFERENCES "organisations"("id"),
5
5
  "xero_contact_id" text NOT NULL,
6
6
  "name" text NOT NULL,
@@ -14,9 +14,9 @@ CREATE TABLE "xero_contacts" (
14
14
  CONSTRAINT "xero_contacts_pk" PRIMARY KEY("organisation_id", "xero_contact_id")
15
15
  );
16
16
  --> statement-breakpoint
17
- CREATE INDEX "xero_contacts_org_name" ON "xero_contacts" ("organisation_id", lower("name"));
17
+ CREATE INDEX IF NOT EXISTS "xero_contacts_org_name" ON "xero_contacts" ("organisation_id", lower("name"));
18
18
  --> statement-breakpoint
19
- CREATE TABLE "xero_items" (
19
+ CREATE TABLE IF NOT EXISTS "xero_items" (
20
20
  "organisation_id" uuid NOT NULL REFERENCES "organisations"("id"),
21
21
  "xero_item_id" text NOT NULL,
22
22
  "code" text NOT NULL,
@@ -32,7 +32,7 @@ CREATE TABLE "xero_items" (
32
32
  CONSTRAINT "xero_items_org_code" UNIQUE("organisation_id", "code")
33
33
  );
34
34
  --> statement-breakpoint
35
- CREATE TABLE "xero_sync_runs" (
35
+ CREATE TABLE IF NOT EXISTS "xero_sync_runs" (
36
36
  "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
37
37
  "organisation_id" uuid NOT NULL REFERENCES "organisations"("id"),
38
38
  "status" text DEFAULT 'running' NOT NULL,
@@ -46,4 +46,4 @@ CREATE TABLE "xero_sync_runs" (
46
46
  CONSTRAINT "xero_sync_runs_status_valid" CHECK ("status" in ('running', 'succeeded', 'failed'))
47
47
  );
48
48
  --> statement-breakpoint
49
- CREATE INDEX "xero_sync_runs_org_started" ON "xero_sync_runs" ("organisation_id", "started_at");
49
+ CREATE INDEX IF NOT EXISTS "xero_sync_runs_org_started" ON "xero_sync_runs" ("organisation_id", "started_at");
@@ -1,7 +1,7 @@
1
1
  -- One row for each issued Flow invoice version sent to Xero as a draft. It
2
2
  -- holds the exact request and its Idempotency-Key from before the request
3
3
  -- leaves, so an uncertain outcome is resolved without a second invoice.
4
- CREATE TABLE "xero_invoice_pushes" (
4
+ CREATE TABLE IF NOT EXISTS "xero_invoice_pushes" (
5
5
  "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
6
6
  "organisation_id" uuid NOT NULL,
7
7
  "invoice_id" uuid NOT NULL,
@@ -29,4 +29,4 @@ CREATE TABLE "xero_invoice_pushes" (
29
29
  CONSTRAINT "xero_invoice_pushes_settled_has_id" CHECK ("state" in ('dispatching', 'ambiguous') or "xero_invoice_id" is not null)
30
30
  );
31
31
  --> statement-breakpoint
32
- CREATE INDEX "xero_invoice_pushes_org_invoice" ON "xero_invoice_pushes" ("organisation_id", "invoice_id");
32
+ CREATE INDEX IF NOT EXISTS "xero_invoice_pushes_org_invoice" ON "xero_invoice_pushes" ("organisation_id", "invoice_id");
@@ -1,6 +1,6 @@
1
1
  -- Where the next read of Xero payments starts, for each Flow organisation.
2
2
  -- (This is the Xero adapter's own migration journal, not the core's.)
3
- CREATE TABLE "xero_payment_cursors" (
3
+ CREATE TABLE IF NOT EXISTS "xero_payment_cursors" (
4
4
  "organisation_id" uuid PRIMARY KEY NOT NULL REFERENCES "organisations"("id"),
5
5
  "payments_since" timestamp with time zone NOT NULL,
6
6
  "updated_at" timestamp with time zone DEFAULT now() NOT NULL
@@ -4,7 +4,7 @@
4
4
  -- without spending Xero calls. A row is upserted by its Xero id, and changes
5
5
  -- only when its content hash changes. Free text (references, line
6
6
  -- descriptions) is kept only with XERO_KEEP_REFERENCE=on.
7
- CREATE TABLE "xero_invoices" (
7
+ CREATE TABLE IF NOT EXISTS "xero_invoices" (
8
8
  "organisation_id" uuid NOT NULL REFERENCES "organisations"("id"),
9
9
  "xero_invoice_id" text NOT NULL,
10
10
  "type" text NOT NULL,
@@ -30,11 +30,11 @@ CREATE TABLE "xero_invoices" (
30
30
  CONSTRAINT "xero_invoices_pk" PRIMARY KEY("organisation_id", "xero_invoice_id")
31
31
  );
32
32
  --> statement-breakpoint
33
- CREATE INDEX "xero_invoices_number" ON "xero_invoices" ("organisation_id", "invoice_number");
33
+ CREATE INDEX IF NOT EXISTS "xero_invoices_number" ON "xero_invoices" ("organisation_id", "invoice_number");
34
34
  --> statement-breakpoint
35
- CREATE INDEX "xero_invoices_contact" ON "xero_invoices" ("organisation_id", "xero_contact_id", "issued_on");
35
+ CREATE INDEX IF NOT EXISTS "xero_invoices_contact" ON "xero_invoices" ("organisation_id", "xero_contact_id", "issued_on");
36
36
  --> statement-breakpoint
37
- CREATE TABLE "xero_credit_notes" (
37
+ CREATE TABLE IF NOT EXISTS "xero_credit_notes" (
38
38
  "organisation_id" uuid NOT NULL REFERENCES "organisations"("id"),
39
39
  "xero_credit_note_id" text NOT NULL,
40
40
  "type" text NOT NULL,
@@ -57,7 +57,7 @@ CREATE TABLE "xero_credit_notes" (
57
57
  CONSTRAINT "xero_credit_notes_pk" PRIMARY KEY("organisation_id", "xero_credit_note_id")
58
58
  );
59
59
  --> statement-breakpoint
60
- CREATE TABLE "xero_payments" (
60
+ CREATE TABLE IF NOT EXISTS "xero_payments" (
61
61
  "organisation_id" uuid NOT NULL REFERENCES "organisations"("id"),
62
62
  "xero_payment_id" text NOT NULL,
63
63
  "payment_type" text,
@@ -75,12 +75,12 @@ CREATE TABLE "xero_payments" (
75
75
  CONSTRAINT "xero_payments_pk" PRIMARY KEY("organisation_id", "xero_payment_id")
76
76
  );
77
77
  --> statement-breakpoint
78
- CREATE INDEX "xero_payments_invoice" ON "xero_payments" ("organisation_id", "xero_invoice_id");
78
+ CREATE INDEX IF NOT EXISTS "xero_payments_invoice" ON "xero_payments" ("organisation_id", "xero_invoice_id");
79
79
  --> statement-breakpoint
80
80
  -- Where the next read of each resource starts. `modified_since` is the
81
81
  -- keyset: the next read asks Xero for records changed since then, oldest
82
82
  -- change first. `page` moves on only when one second holds a full page.
83
- CREATE TABLE "xero_mirror_cursors" (
83
+ CREATE TABLE IF NOT EXISTS "xero_mirror_cursors" (
84
84
  "organisation_id" uuid NOT NULL REFERENCES "organisations"("id"),
85
85
  "resource" text NOT NULL,
86
86
  "modified_since" timestamp with time zone,
@@ -2,9 +2,15 @@
2
2
  -- sent approved, whether Xero emailed it to the customer. Xero's email action
3
3
  -- is not idempotent, so Flow records `sending` before the call and retries
4
4
  -- only a refusal (`failed`), never an unknown outcome.
5
- ALTER TABLE "xero_invoice_pushes" ADD COLUMN "send_mode" text DEFAULT 'accounting_draft' NOT NULL;--> statement-breakpoint
6
- ALTER TABLE "xero_invoice_pushes" ADD COLUMN "email_state" text;--> statement-breakpoint
7
- ALTER TABLE "xero_invoice_pushes" ADD COLUMN "email_error" text;--> statement-breakpoint
8
- ALTER TABLE "xero_invoice_pushes" ADD COLUMN "emailed_at" timestamp with time zone;--> statement-breakpoint
9
- ALTER TABLE "xero_invoice_pushes" ADD CONSTRAINT "xero_invoice_pushes_send_mode_valid" CHECK ("send_mode" in ('accounting_draft', 'approve_and_email'));--> statement-breakpoint
10
- ALTER TABLE "xero_invoice_pushes" ADD CONSTRAINT "xero_invoice_pushes_email_state_valid" CHECK ("email_state" is null or "email_state" in ('pending', 'sending', 'sent', 'failed', 'unknown'));
5
+ ALTER TABLE "xero_invoice_pushes" ADD COLUMN IF NOT EXISTS "send_mode" text DEFAULT 'accounting_draft' NOT NULL;--> statement-breakpoint
6
+ ALTER TABLE "xero_invoice_pushes" ADD COLUMN IF NOT EXISTS "email_state" text;--> statement-breakpoint
7
+ ALTER TABLE "xero_invoice_pushes" ADD COLUMN IF NOT EXISTS "email_error" text;--> statement-breakpoint
8
+ ALTER TABLE "xero_invoice_pushes" ADD COLUMN IF NOT EXISTS "emailed_at" timestamp with time zone;--> statement-breakpoint
9
+ DO $flow_guard$ BEGIN
10
+ ALTER TABLE "xero_invoice_pushes" ADD CONSTRAINT "xero_invoice_pushes_send_mode_valid" CHECK ("send_mode" in ('accounting_draft', 'approve_and_email'));
11
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
12
+ END $flow_guard$;--> statement-breakpoint
13
+ DO $flow_guard$ BEGIN
14
+ ALTER TABLE "xero_invoice_pushes" ADD CONSTRAINT "xero_invoice_pushes_email_state_valid" CHECK ("email_state" is null or "email_state" in ('pending', 'sending', 'sent', 'failed', 'unknown'));
15
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
16
+ END $flow_guard$;
@@ -34,14 +34,16 @@ const BODY = `<div class="fx-app">
34
34
  <div class="fx-card fx-message__card fx-state-no-access" id="no-access"></div>
35
35
  </section>
36
36
 
37
- <div class="fx-state-signed-in fx-shell">
37
+ <div class="fx-state-signed-in fx-shell" id="shell">
38
38
  <aside class="fx-side">
39
- <a class="fx-brand" href="/">${FLOW_MARK}<span>Flow</span></a>
39
+ <div class="fx-side__head">
40
+ <a class="fx-brand" href="/">${FLOW_MARK}<span>Flow</span></a>
41
+ <button type="button" class="fx-btn fx-btn--ghost fx-btn--icon" id="menu-toggle" aria-controls="nav" aria-label="Menu"></button>
42
+ </div>
40
43
  <nav class="fx-nav" id="nav" aria-label="Flow"></nav>
41
44
  <div class="fx-side__end">
42
45
  <div id="user-button" class="fx-side__user"></div>
43
- <button type="button" class="fx-btn fx-btn--ghost fx-btn--icon" id="report-problem" title="Report a problem" aria-label="Report a problem" hidden></button>
44
- <button type="button" class="fx-btn fx-btn--ghost fx-btn--icon fx-mode-toggle" data-mode-toggle>Light or dark</button>
46
+ <button type="button" class="fx-btn fx-btn--ghost fx-btn--icon" id="search" title="Search (Ctrl+K)" aria-label="Search" aria-keyshortcuts="Control+K Meta+K"></button>
45
47
  </div>
46
48
  </aside>
47
49
  <main id="main" class="fx-main" tabindex="-1"></main>
package/server/ui/app.js CHANGED
@@ -14,6 +14,7 @@ import { automationSection } from '/assets/ui/automations.js';
14
14
  import { renderInvoicingSettings, sendModeSection } from '/assets/ui/invoicing-settings.js';
15
15
  import { renderJobs } from '/assets/ui/jobs.js';
16
16
  import { startMonitoring } from '/assets/ui/monitoring.js';
17
+ import { openSearch } from '/assets/ui/search.js';
17
18
 
18
19
  const $ = (id) => document.getElementById(id);
19
20
 
@@ -208,6 +209,8 @@ const ICON_PATHS = {
208
209
  plug: '<path d="M9 3v5M15 3v5M6 8h12v3a6 6 0 01-12 0zM12 17v4"/>',
209
210
  person: '<circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 3.6-7 8-7s8 3 8 7"/>',
210
211
  flag: '<path d="M5 21V4M5 4h11l-2 4 2 4H5"/>',
212
+ search: '<circle cx="11" cy="11" r="6.5"/><path d="M16 16l4.5 4.5"/>',
213
+ menu: '<path d="M4 6h16M4 12h16M4 18h16"/>',
211
214
  palette: '<path d="M12 3a9 9 0 100 18c1.1 0 1.5-.8 1.5-1.6 0-1-.8-1.4-.8-2.4 0-.9.7-1.5 1.6-1.5H17a4 4 0 004-4c0-4.7-4-8.5-9-8.5z"/><circle cx="7.5" cy="11" r="1"/><circle cx="10" cy="7" r="1"/><circle cx="15" cy="7" r="1"/>',
212
215
  };
213
216
 
@@ -217,6 +220,87 @@ function icon(name) {
217
220
  return node;
218
221
  }
219
222
 
223
+ // The menu button at the top of the sidebar. On a wide screen it folds the
224
+ // sidebar to its icons and back, and the choice stays in this browser. On a
225
+ // narrow screen the sidebar is a bar across the top, and the button opens
226
+ // the menu below it; choosing a page, Escape or a click outside closes it.
227
+ const NARROW = window.matchMedia('(max-width: 820px)');
228
+ const FOLDED_KEY = 'flow_sidebar_folded';
229
+
230
+ function readFolded() {
231
+ try { return localStorage.getItem(FOLDED_KEY) === '1'; } catch { return false; }
232
+ }
233
+
234
+ function drawMenu() {
235
+ const shell = $('shell');
236
+ const folded = readFolded();
237
+ shell.toggleAttribute('data-folded', folded);
238
+ const open = NARROW.matches ? shell.hasAttribute('data-menu-open') : !folded;
239
+ const toggle = $('menu-toggle');
240
+ const label = NARROW.matches ? (open ? 'Close the menu' : 'Open the menu') : (folded ? 'Show the menu' : 'Fold the menu');
241
+ toggle.setAttribute('aria-expanded', String(open));
242
+ toggle.setAttribute('aria-label', label);
243
+ toggle.title = label;
244
+ // Folded, a page's name shows when the pointer rests on its icon.
245
+ for (const link of document.querySelectorAll('[data-nav]')) {
246
+ if (folded && !NARROW.matches) link.title = link.dataset.label;
247
+ else link.removeAttribute('title');
248
+ }
249
+ }
250
+
251
+ function closeMenu() {
252
+ if (!$('shell').hasAttribute('data-menu-open')) return;
253
+ $('shell').removeAttribute('data-menu-open');
254
+ drawMenu();
255
+ }
256
+
257
+ function toggleMenu() {
258
+ if (NARROW.matches) {
259
+ $('shell').toggleAttribute('data-menu-open');
260
+ } else {
261
+ try { localStorage.setItem(FOLDED_KEY, readFolded() ? '0' : '1'); } catch { /* the choice is only a convenience */ }
262
+ }
263
+ drawMenu();
264
+ }
265
+
266
+ NARROW.addEventListener('change', () => { $('shell').removeAttribute('data-menu-open'); drawMenu(); });
267
+ document.addEventListener('keydown', (event) => {
268
+ if (event.key === 'Escape' && $('shell')?.hasAttribute('data-menu-open')) {
269
+ closeMenu();
270
+ $('menu-toggle').focus();
271
+ }
272
+ });
273
+ document.addEventListener('click', (event) => {
274
+ if (!event.target.closest?.('.fx-side') || event.target.closest?.('[data-nav]')) closeMenu();
275
+ });
276
+
277
+ // Search, from the sidebar foot or Ctrl+K (⌘K): the pages this person may
278
+ // open, invoices and jobs (search.js).
279
+ function search() {
280
+ const views = Object.entries(VIEWS)
281
+ .filter(([, view]) => !view.ownerOnly || me?.person.role === 'owner')
282
+ .map(([name, view]) => ({ name, label: view.label, icon: view.icon }));
283
+ openSearch({ h, flow, go, icon, views });
284
+ }
285
+
286
+ document.addEventListener('keydown', (event) => {
287
+ if (event.key.toLowerCase() !== 'k' || !(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return;
288
+ if (document.body.dataset.state !== 'signed-in') return;
289
+ event.preventDefault();
290
+ search();
291
+ });
292
+
293
+ // "Report a problem" in Clerk's account menu, between "Manage account" and
294
+ // "Sign out". Clerk mounts and unmounts the icon itself.
295
+ function reportMenuItem(openReport) {
296
+ return {
297
+ label: 'Report a problem',
298
+ onClick: () => { openReport().catch((error) => toast(error.message, 'danger')); },
299
+ mountIcon: (element) => element.replaceChildren(icon('flag')),
300
+ unmountIcon: (element) => element.replaceChildren(),
301
+ };
302
+ }
303
+
220
304
  // The view and, for one record, its id: /invoices/<id> is ['invoices', '<id>'].
221
305
  function currentView() {
222
306
  const [name, id = null] = location.pathname.replace(/^\/+|\/+$/g, '').split('/');
@@ -230,7 +314,7 @@ async function route() {
230
314
  const [view, id] = currentView();
231
315
  const path = id ? `/${view}/${id}` : `/${view}`;
232
316
  if (location.pathname !== path) history.replaceState(null, '', path);
233
- for (const link of document.querySelectorAll('[data-nav]')) {
317
+ for (const link of document.querySelectorAll('a[data-nav]')) {
234
318
  link.setAttribute('aria-current', link.dataset.nav === view ? 'page' : 'false');
235
319
  }
236
320
  main.replaceChildren();
@@ -242,7 +326,7 @@ async function route() {
242
326
 
243
327
  // Opens a path in place; the address changes, the page stays.
244
328
  function go(path, { replace = false } = {}) {
245
- if (path !== location.pathname) history[replace ? 'replaceState' : 'pushState'](null, '', path);
329
+ if (path !== location.pathname + location.search) history[replace ? 'replaceState' : 'pushState'](null, '', path);
246
330
  route();
247
331
  }
248
332
 
@@ -286,14 +370,33 @@ function renderShell() {
286
370
  if (view.ownerOnly && me?.person.role !== 'owner') continue;
287
371
  if (view.group && view.group !== group) items.push(h('span', { class: 'fx-nav__group' }, view.group));
288
372
  group = view.group;
289
- items.push(h('a', { href: `/${name}`, 'data-nav': name, onclick: navigate }, icon(view.icon), h('span', {}, view.label)));
373
+ items.push(h('a', { href: `/${name}`, 'data-nav': name, 'data-label': view.label, onclick: navigate }, icon(view.icon), h('span', { class: 'fx-nav__label' }, view.label)));
290
374
  }
291
- nav.replaceChildren(...items);
292
- mountClerk('userButton', $('user-button'), { afterSignOutUrl: '/sign-in', showName: false });
375
+ // "Report a problem" sits at the foot of the menu, above the account chip,
376
+ // while Sentry and its report form are on (monitoring.js).
377
+ const report = h('button', { type: 'button', class: 'fx-nav__report', 'data-nav': 'report', 'data-label': 'Report a problem', hidden: true },
378
+ icon('flag'), h('span', { class: 'fx-nav__label' }, 'Report a problem'));
379
+ nav.replaceChildren(...items, report);
380
+ $('menu-toggle').replaceChildren(icon('menu'));
381
+ $('menu-toggle').onclick = toggleMenu;
382
+ drawMenu();
383
+ // The sidebar foot shows who is signed in: photo or initial, then the name
384
+ // (the email when the account has no name). It opens the account menu.
385
+ const userButton = { afterSignOutUrl: '/sign-in', showName: true };
386
+ $('search').replaceChildren(icon('search'));
387
+ $('search').onclick = search;
388
+ mountClerk('userButton', $('user-button'), userButton);
293
389
  // Errors and problem reports go to the installation's own Sentry, when the
294
- // Sentry integration is on (monitoring.js). Flow works the same without it.
295
- $('report-problem').replaceChildren(icon('flag'));
296
- startMonitoring({ flow, person: me.person, button: $('report-problem') })
390
+ // Sentry integration is on (monitoring.js). Then "Report a problem" shows
391
+ // in the menu and in the account menu. Flow works the same without it.
392
+ startMonitoring({ flow, person: me.person })
393
+ .then((openReport) => {
394
+ if (!openReport) return;
395
+ report.hidden = false;
396
+ report.onclick = () => { openReport().catch((error) => toast(error.message, 'danger')); };
397
+ unmountClerk($('user-button'));
398
+ mountClerk('userButton', $('user-button'), { ...userButton, customMenuItems: [reportMenuItem(openReport)] });
399
+ })
297
400
  .catch((error) => console.warn(`[monitoring] ${error.message}`));
298
401
  window.addEventListener('popstate', route);
299
402
  return route();