@arcforgelabs/flow 2026.10.32 → 2026.10.34
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 +9 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
- package/server/api/custom-fields.js +114 -0
- package/server/api/invoicing.js +58 -25
- package/server/api/work-moves.js +80 -0
- package/server/app.js +16 -0
- package/server/core/custom-fields.js +300 -0
- package/server/core/invoicing-rules.js +50 -4
- package/server/core/job-board.js +17 -4
- package/server/core/ledger.js +242 -6
- package/server/core/pipeline.js +29 -21
- package/server/core/read-before-act.js +96 -0
- package/server/core/reconcile.js +350 -0
- package/server/core/sending.js +44 -6
- package/server/core/tracing.js +64 -0
- package/server/db/migrations/0017_sync_basis.sql +1 -0
- package/server/db/migrations/0018_custom_fields.sql +78 -0
- package/server/db/migrations/meta/0017_snapshot.json +3867 -0
- package/server/db/migrations/meta/0018_snapshot.json +4289 -0
- package/server/db/migrations/meta/_journal.json +14 -0
- package/server/db/schema.js +62 -0
- package/server/index.js +11 -1
- package/server/integrations/port.js +10 -1
- package/server/integrations/seazona/act.js +48 -0
- package/server/integrations/seazona/feed.js +70 -50
- package/server/integrations/seazona/governor.js +28 -2
- package/server/integrations/seazona/index.js +107 -4
- package/server/integrations/seazona/lanes.js +81 -8
- package/server/integrations/seazona/ledger.js +14 -9
- package/server/integrations/seazona/migrations/0005_record_reread.sql +1 -0
- package/server/integrations/seazona/migrations/0006_sweep_findings.sql +11 -0
- package/server/integrations/seazona/migrations/0007_stage_moves.sql +18 -0
- package/server/integrations/seazona/migrations/meta/0005_snapshot.json +1329 -0
- package/server/integrations/seazona/migrations/meta/0006_snapshot.json +1403 -0
- package/server/integrations/seazona/migrations/meta/0007_snapshot.json +1519 -0
- package/server/integrations/seazona/migrations/meta/_journal.json +21 -0
- package/server/integrations/seazona/reconcile.js +40 -0
- package/server/integrations/seazona/scheduler.js +32 -4
- package/server/integrations/seazona/schema.js +47 -0
- package/server/integrations/seazona/settings.js +26 -0
- package/server/integrations/seazona/stage-moves.js +135 -0
- package/server/integrations/seazona/store.js +24 -2
- package/server/integrations/seazona/sweep.js +574 -0
- package/server/integrations/seazona/tables.js +3 -0
- package/server/integrations/sentry/index.js +61 -10
- package/server/integrations/xero/index.js +4 -0
- package/server/integrations/xero/webhooks.js +35 -0
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +40 -2
- package/server/ui/custom-fields.js +190 -0
- package/server/ui/flow.css +57 -9
- package/server/ui/invoicing.js +184 -22
- package/server/ui/job-board.js +2 -1
- package/server/ui/jobs.js +22 -2
- package/server/ui/monitoring.js +44 -2
- package/server/ui/routes.js +1 -1
|
@@ -36,6 +36,27 @@
|
|
|
36
36
|
"when": 1792453111948,
|
|
37
37
|
"tag": "0004_history_import",
|
|
38
38
|
"breakpoints": true
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"idx": 5,
|
|
42
|
+
"version": "7",
|
|
43
|
+
"when": 1792458014317,
|
|
44
|
+
"tag": "0005_record_reread",
|
|
45
|
+
"breakpoints": true
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"idx": 6,
|
|
49
|
+
"version": "7",
|
|
50
|
+
"when": 1792459854631,
|
|
51
|
+
"tag": "0006_sweep_findings",
|
|
52
|
+
"breakpoints": true
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"idx": 7,
|
|
56
|
+
"version": "7",
|
|
57
|
+
"when": 1792472936253,
|
|
58
|
+
"tag": "0007_stage_moves",
|
|
59
|
+
"breakpoints": true
|
|
39
60
|
}
|
|
40
61
|
]
|
|
41
62
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { readBillableWork } from './feed.js';
|
|
2
|
+
|
|
3
|
+
// The billable jobs in Flow's copy of Seazona, by day, behind the port's
|
|
4
|
+
// `billableJobs` (the daily reconcile, server/core/reconcile.js). It walks the
|
|
5
|
+
// billing feed from its start, so a job counts here exactly when the feed
|
|
6
|
+
// gives it to the ledger, on the day the ledger files it (its `jobDate`). It
|
|
7
|
+
// reads Flow's copy only; it never calls Seazona.
|
|
8
|
+
//
|
|
9
|
+
// A feed key only moves forward, so an order read again while the walk runs
|
|
10
|
+
// is counted once, and none is missed.
|
|
11
|
+
export async function readBillableJobs(db, { from, to, pageSize = 1000 } = {}) {
|
|
12
|
+
const jobs = new Map();
|
|
13
|
+
let cursor = null;
|
|
14
|
+
for (;;) {
|
|
15
|
+
const page = await readBillableWork(db, { cursor, limit: pageSize });
|
|
16
|
+
for (const { ledgerJob: job } of page.items) {
|
|
17
|
+
// A job with no lines never reaches the ledger (pipeline.js, ingestWork).
|
|
18
|
+
if (!job.lines.length) {
|
|
19
|
+
jobs.delete(job.sourceJobRef);
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (!job.jobDate || (from && job.jobDate < from) || (to && job.jobDate > to)) {
|
|
23
|
+
jobs.delete(job.sourceJobRef);
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
// Unknown when a line has no amount: its price item fills it.
|
|
27
|
+
const priced = job.lines.every((l) => Number.isInteger(l.unitAmountCents));
|
|
28
|
+
jobs.set(job.sourceJobRef, {
|
|
29
|
+
ref: job.sourceJobRef,
|
|
30
|
+
day: job.jobDate,
|
|
31
|
+
subtotalCents: priced ? job.lines.reduce((sum, l) => sum + l.unitAmountCents * l.quantity, 0) : null,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
// A job that stopped being billable, or was deleted, is not counted.
|
|
35
|
+
for (const { sourceId } of page.withdrawn) jobs.delete(sourceId);
|
|
36
|
+
if (!page.more) break;
|
|
37
|
+
cursor = page.cursor;
|
|
38
|
+
}
|
|
39
|
+
return { jobs: [...jobs.values()] };
|
|
40
|
+
}
|
|
@@ -6,6 +6,11 @@ import { readLaneStates } from './store.js';
|
|
|
6
6
|
// the next lane is due, between 15 seconds and 30 minutes. One run at a time.
|
|
7
7
|
// A stopped lane (issue #101) is not due until a person or a release resets it.
|
|
8
8
|
//
|
|
9
|
+
// Issue #143: a run that has not ended after `stuckMs` is stuck (for example a
|
|
10
|
+
// database call that never answers). It is reported once, /health says so,
|
|
11
|
+
// and the scheduler goes on with the next run without it. Lane claims
|
|
12
|
+
// (store.js, claimLane) keep two runs off one lane.
|
|
13
|
+
//
|
|
9
14
|
// Issue #101: each held failure is reported once to the error services
|
|
10
15
|
// (log.error, server/core/monitoring.js): when a lane starts failing or its
|
|
11
16
|
// failure code changes, and when records are quarantined. A repeat of the
|
|
@@ -23,12 +28,15 @@ export function createSeazonaScheduler({
|
|
|
23
28
|
log = console,
|
|
24
29
|
timers = { setTimeout, clearTimeout },
|
|
25
30
|
firstDelayMs = 5_000,
|
|
31
|
+
stuckMs = settings?.sync?.stuckLaneMs ?? 20 * 60_000,
|
|
26
32
|
} = {}) {
|
|
27
33
|
let timer = null;
|
|
28
34
|
let running = null;
|
|
29
35
|
let stopped = false;
|
|
30
36
|
let nextRunAt = null;
|
|
31
37
|
let last = null;
|
|
38
|
+
// When the run that is stuck now started, or null.
|
|
39
|
+
let stuckSince = null;
|
|
32
40
|
// lane -> the failure code last reported, while that lane keeps failing.
|
|
33
41
|
const reported = new Map();
|
|
34
42
|
const quarantineAt = settings?.sync?.orderQuarantineFailures ?? null;
|
|
@@ -45,6 +53,8 @@ export function createSeazonaScheduler({
|
|
|
45
53
|
reported.delete(lane);
|
|
46
54
|
const count = newlyQuarantined(outcome);
|
|
47
55
|
if (count) log.error?.(`[seazona] ${lane}: records quarantined after repeated failed reads`);
|
|
56
|
+
// Issue #126: what the sweep found, once per run that found it.
|
|
57
|
+
for (const line of outcome.reports ?? []) log.error?.(`[seazona] ${lane}: ${line}`);
|
|
48
58
|
continue;
|
|
49
59
|
}
|
|
50
60
|
if (reported.get(lane) === outcome.code) {
|
|
@@ -78,8 +88,9 @@ export function createSeazonaScheduler({
|
|
|
78
88
|
async function tick() {
|
|
79
89
|
timer = null;
|
|
80
90
|
if (stopped) return;
|
|
81
|
-
|
|
82
|
-
|
|
91
|
+
const startedAt = new Date(Number(now())).toISOString();
|
|
92
|
+
const turn = { abandoned: false };
|
|
93
|
+
const thisRun = (async () => {
|
|
83
94
|
try {
|
|
84
95
|
const result = await run();
|
|
85
96
|
const failed = Object.entries(result.results).filter(([, lane]) => !lane.ok);
|
|
@@ -90,7 +101,24 @@ export function createSeazonaScheduler({
|
|
|
90
101
|
log.error?.(`[seazona] sync run failed: ${last.error}`);
|
|
91
102
|
}
|
|
92
103
|
})();
|
|
93
|
-
|
|
104
|
+
running = thisRun;
|
|
105
|
+
const watchdog = timers.setTimeout(() => {
|
|
106
|
+
if (stopped || turn.abandoned) return;
|
|
107
|
+
turn.abandoned = true;
|
|
108
|
+
stuckSince = startedAt;
|
|
109
|
+
running = null;
|
|
110
|
+
log.error?.(`[seazona] a sync run has not ended after ${Math.round(stuckMs / 60_000)} minutes; the next run starts without it`);
|
|
111
|
+
schedule(MIN_DELAY_MS);
|
|
112
|
+
}, stuckMs);
|
|
113
|
+
watchdog?.unref?.();
|
|
114
|
+
await thisRun;
|
|
115
|
+
timers.clearTimeout(watchdog);
|
|
116
|
+
// A stuck run that ends late leaves the schedule to the run after it.
|
|
117
|
+
if (turn.abandoned) {
|
|
118
|
+
if (stuckSince === startedAt) stuckSince = null;
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
stuckSince = null;
|
|
94
122
|
running = null;
|
|
95
123
|
schedule(await nextDelayMs().catch(() => MIN_DELAY_MS));
|
|
96
124
|
}
|
|
@@ -108,7 +136,7 @@ export function createSeazonaScheduler({
|
|
|
108
136
|
await running;
|
|
109
137
|
},
|
|
110
138
|
status() {
|
|
111
|
-
return { running: Boolean(running), nextRunAt, lastRun: last };
|
|
139
|
+
return { running: Boolean(running), nextRunAt, lastRun: last, stuckSince };
|
|
112
140
|
},
|
|
113
141
|
};
|
|
114
142
|
}
|
|
@@ -170,9 +170,12 @@ export const seazonaOrderReadFailures = pgTable('seazona_order_read_failures', {
|
|
|
170
170
|
// Issue #101: each failed detail read of an invoice or a client, per record.
|
|
171
171
|
// Orders keep their own table (issue #12). After a few failures in a row the
|
|
172
172
|
// record is quarantined: the lane skips it and its cursor moves on.
|
|
173
|
+
// Issue #142: `detail_id` is the id its detail path needs (an invoice's key
|
|
174
|
+
// is its number), so the lane can read it again when its wait ends.
|
|
173
175
|
export const seazonaRecordReadFailures = pgTable('seazona_record_read_failures', {
|
|
174
176
|
resource: text('resource').notNull(),
|
|
175
177
|
recordKey: text('record_key').notNull(),
|
|
178
|
+
detailId: text('detail_id'),
|
|
176
179
|
failureCount: integer('failure_count').notNull(),
|
|
177
180
|
lastAttemptAt: at('last_attempt_at').notNull(),
|
|
178
181
|
lastHttpStatus: integer('last_http_status'),
|
|
@@ -271,3 +274,47 @@ export const seazonaHistoryJobs = pgTable('seazona_history_jobs', {
|
|
|
271
274
|
index('seazona_history_jobs_invoice').on(t.invoiceNumber),
|
|
272
275
|
index('seazona_history_jobs_changed').on(t.lastChangedAt),
|
|
273
276
|
]);
|
|
277
|
+
|
|
278
|
+
// Issue #126: what the sweep found that the cursor lanes missed, one row per
|
|
279
|
+
// record and kind. `changed` and `created`: an edit or a new record the lanes
|
|
280
|
+
// passed by. `deleted`: an order Seazona answered 404 for. `missing`: an
|
|
281
|
+
// invoice or client that Seazona no longer lists; the row stays, and the
|
|
282
|
+
// mark clears when Seazona lists the record again.
|
|
283
|
+
export const seazonaSweepFindings = pgTable('seazona_sweep_findings', {
|
|
284
|
+
resource: text('resource').notNull(),
|
|
285
|
+
recordKey: text('record_key').notNull(),
|
|
286
|
+
kind: text('kind').notNull(),
|
|
287
|
+
firstFoundAt: at('first_found_at').notNull(),
|
|
288
|
+
lastFoundAt: at('last_found_at').notNull(),
|
|
289
|
+
// Status codes and statuses only. Never patient data.
|
|
290
|
+
detail: jsonb('detail'),
|
|
291
|
+
}, (t) => [
|
|
292
|
+
primaryKey({ columns: [t.resource, t.recordKey, t.kind] }),
|
|
293
|
+
index('seazona_sweep_findings_found').on(t.lastFoundAt),
|
|
294
|
+
]);
|
|
295
|
+
|
|
296
|
+
// A person's stage move on the Jobs board that Seazona must make, because
|
|
297
|
+
// Seazona's API cannot change an order's status (docs/SEAZONA.md, "Stage
|
|
298
|
+
// moves"). One row per order: the latest move. `state` is waiting until the
|
|
299
|
+
// sync reads the new status (done), a day passes (expired), or the person
|
|
300
|
+
// takes it back (cancelled). Statuses only; never patient data.
|
|
301
|
+
export const seazonaStageMoves = pgTable('seazona_stage_moves', {
|
|
302
|
+
orderId: text('order_id').primaryKey(),
|
|
303
|
+
fromStatus: text('from_status'),
|
|
304
|
+
toStatus: text('to_status').notNull(),
|
|
305
|
+
requestedBy: text('requested_by'),
|
|
306
|
+
requestedAt: at('requested_at').notNull(),
|
|
307
|
+
expiresAt: at('expires_at').notNull(),
|
|
308
|
+
state: text('state').notNull().default('waiting'),
|
|
309
|
+
settledAt: at('settled_at'),
|
|
310
|
+
}, (t) => [
|
|
311
|
+
index('seazona_stage_moves_state').on(t.state, t.expiresAt),
|
|
312
|
+
]);
|
|
313
|
+
|
|
314
|
+
// Each person's own Seazona choices. `ask_before_stage_moves`: show the
|
|
315
|
+
// "Move in Seazona" dialog before a stage move on the Jobs board.
|
|
316
|
+
export const seazonaPersonSettings = pgTable('seazona_person_settings', {
|
|
317
|
+
personId: text('person_id').primaryKey(),
|
|
318
|
+
askBeforeStageMoves: boolean('ask_before_stage_moves').notNull().default(true),
|
|
319
|
+
updatedAt: at('updated_at').notNull(),
|
|
320
|
+
});
|
|
@@ -55,6 +55,16 @@ export function normaliseBaseUrl(value) {
|
|
|
55
55
|
return `${url.origin}${path}/v1`;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
function orderUrlTemplate(value) {
|
|
59
|
+
const raw = text(value);
|
|
60
|
+
if (!raw.includes('{ref}')) return null;
|
|
61
|
+
try {
|
|
62
|
+
return new URL(raw.replaceAll('{ref}', 'x')).protocol === 'https:' ? raw : null;
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
58
68
|
// Returns the resolved settings, or throws SeazonaSettingsError. `mode` is
|
|
59
69
|
// null when SEAZONA_MODE is unset: the adapter is then not configured.
|
|
60
70
|
export function resolveSeazonaSettings(env = process.env) {
|
|
@@ -102,7 +112,16 @@ export function resolveSeazonaSettings(env = process.env) {
|
|
|
102
112
|
// The history backfill (docs/SEAZONA.md, "History import"). It calls
|
|
103
113
|
// Seazona only while a person has started one.
|
|
104
114
|
history: { cadenceMs: ms('SEAZONA_HISTORY_CADENCE_MS', 2 * MINUTE, MINUTE, DAY_MS), freshnessMs: 30 * DAY_MS },
|
|
115
|
+
// Issue #126: the slow re-check sweep (sweep.js). A few calls a run,
|
|
116
|
+
// inside a small daily share.
|
|
117
|
+
sweep: { cadenceMs: ms('SEAZONA_SWEEP_CADENCE_MS', 15 * MINUTE, MINUTE, DAY_MS), freshnessMs: 2 * DAY_MS },
|
|
105
118
|
},
|
|
119
|
+
// Issue #126: calls the sweep makes per run and per UTC day, at most. 0 a
|
|
120
|
+
// day turns the sweep off. Each kind of record (old orders, clients,
|
|
121
|
+
// invoices) is checked again at most once a cycle.
|
|
122
|
+
sweepCallsPerRun: clampInt(env.SEAZONA_SWEEP_CALLS_PER_RUN, 10, 1, 25),
|
|
123
|
+
sweepCallsPerDay: clampInt(env.SEAZONA_SWEEP_CALLS_PER_DAY, 100, 0, 1000),
|
|
124
|
+
sweepCycleMs: clampInt(env.SEAZONA_SWEEP_CYCLE_DAYS, 7, 1, 90) * DAY_MS,
|
|
106
125
|
// Calls the history backfill makes per run, at most: a small share of
|
|
107
126
|
// the rate limit, so the live lanes always have room.
|
|
108
127
|
historyCallsPerRun: clampInt(env.SEAZONA_HISTORY_CALLS_PER_RUN, 10, 1, 25),
|
|
@@ -128,6 +147,9 @@ export function resolveSeazonaSettings(env = process.env) {
|
|
|
128
147
|
// Issue #12: after this many failed reads in a row an order waits this long.
|
|
129
148
|
orderQuarantineFailures: clampInt(env.SEAZONA_ORDER_QUARANTINE_FAILURES, 3, 1, 20),
|
|
130
149
|
orderQuarantineMs: ms('SEAZONA_ORDER_QUARANTINE_MS', DAY_MS, 5 * MINUTE, 30 * DAY_MS),
|
|
150
|
+
// Issue #142: the invoice and client lanes read this many quarantined
|
|
151
|
+
// records again per run, once their wait has ended.
|
|
152
|
+
recordRereadsPerRun: clampInt(env.SEAZONA_RECORD_REREADS_PER_RUN, 5, 0, 50),
|
|
131
153
|
// A failed lane waits 1 minute, doubling to at most 6 hours.
|
|
132
154
|
retryBaseMs: MINUTE,
|
|
133
155
|
retryMaxMs: 6 * 60 * MINUTE,
|
|
@@ -149,6 +171,10 @@ export function resolveSeazonaSettings(env = process.env) {
|
|
|
149
171
|
rate,
|
|
150
172
|
// The scheduler starts only in live mode (issue #10).
|
|
151
173
|
scheduled: mode === 'live',
|
|
174
|
+
// The address of one order in Seazona's own screens, with {ref} for the
|
|
175
|
+
// order number, for "Open in Seazona" on a stage move. Optional; one
|
|
176
|
+
// without {ref} is ignored.
|
|
177
|
+
orderUrlTemplate: orderUrlTemplate(env.SEAZONA_ORDER_URL),
|
|
152
178
|
};
|
|
153
179
|
|
|
154
180
|
if (mode === 'live' || mode === 'sandbox') {
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { and, eq, gte, inArray, lt, sql } from 'drizzle-orm';
|
|
2
|
+
import { FlowError } from '../../core/errors.js';
|
|
3
|
+
import { orderStatusLabel } from './map.js';
|
|
4
|
+
import { seazonaOrders, seazonaPersonSettings, seazonaStageMoves } from './schema.js';
|
|
5
|
+
|
|
6
|
+
// Stage moves on the Jobs board (docs/SEAZONA.md, "Stage moves"). Seazona's
|
|
7
|
+
// API cannot change an order's status, so Flow never tries: a move becomes a
|
|
8
|
+
// hand-off. Flow records the move, the person makes it in Seazona, and the
|
|
9
|
+
// next sync that reads the new status settles it. Until then the card shows
|
|
10
|
+
// "Waiting for Seazona" in the new lane. After a day with no change the move
|
|
11
|
+
// expires, the card goes back to Seazona's stage, and a quiet alert says so.
|
|
12
|
+
// Every function here reads and writes Flow's database only.
|
|
13
|
+
|
|
14
|
+
export const HAND_OFF_MS = 24 * 60 * 60_000;
|
|
15
|
+
// How long an expired move stays in the alert.
|
|
16
|
+
const EXPIRED_ALERT_MS = 7 * 24 * 60 * 60_000;
|
|
17
|
+
const MAX_STATUS = 80;
|
|
18
|
+
|
|
19
|
+
// The address that opens one order in Seazona, from SEAZONA_ORDER_URL with
|
|
20
|
+
// its {ref} placeholder, or null when the installation has not set one.
|
|
21
|
+
export function orderUrl(template, ref) {
|
|
22
|
+
if (!template || !template.includes('{ref}')) return null;
|
|
23
|
+
return template.replaceAll('{ref}', encodeURIComponent(ref));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Record a person's move of one order to `toStatus`, and answer the hand-off
|
|
27
|
+
// the board shows. A second move of the same order replaces the first.
|
|
28
|
+
export async function requestStageMove(db, { orderId, toStatus, personId = null, orderUrlTemplate = null, now = Date.now() }) {
|
|
29
|
+
const to = typeof toStatus === 'string' ? toStatus.trim() : '';
|
|
30
|
+
if (!to || to.length > MAX_STATUS) throw new FlowError('invalid', 'toStatus must be a Seazona status');
|
|
31
|
+
const [order] = await db.select().from(seazonaOrders).where(eq(seazonaOrders.id, String(orderId ?? '')));
|
|
32
|
+
if (!order || order.deletedInSourceAt) throw new FlowError('not_found', 'Flow has no Seazona order with that number');
|
|
33
|
+
const known = await db.selectDistinct({ status: seazonaOrders.status }).from(seazonaOrders)
|
|
34
|
+
.where(eq(sql`lower(trim(${seazonaOrders.status}))`, to.toLowerCase()));
|
|
35
|
+
if (!known.length) throw new FlowError('invalid', `Seazona has no status named ${to}`);
|
|
36
|
+
const from = orderStatusLabel(order) || null;
|
|
37
|
+
if (from && from.toLowerCase() === to.toLowerCase()) throw new FlowError('conflict', `The order is already ${from} in Seazona`);
|
|
38
|
+
const at = new Date(Number(now));
|
|
39
|
+
const row = {
|
|
40
|
+
orderId: order.id, fromStatus: from, toStatus: known[0].status.trim(), requestedBy: personId,
|
|
41
|
+
requestedAt: at, expiresAt: new Date(at.getTime() + HAND_OFF_MS), state: 'waiting', settledAt: null,
|
|
42
|
+
};
|
|
43
|
+
await db.insert(seazonaStageMoves).values(row).onConflictDoUpdate({ target: seazonaStageMoves.orderId, set: row });
|
|
44
|
+
return handOff(row, { orderUrlTemplate });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Take a waiting move back: the card returns to Seazona's stage.
|
|
48
|
+
export async function cancelStageMove(db, { orderId, now = Date.now() }) {
|
|
49
|
+
const rows = await db.update(seazonaStageMoves).set({ state: 'cancelled', settledAt: new Date(Number(now)) })
|
|
50
|
+
.where(and(eq(seazonaStageMoves.orderId, String(orderId ?? '')), eq(seazonaStageMoves.state, 'waiting'))).returning();
|
|
51
|
+
return { cancelled: rows.length > 0 };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Settle the waiting moves against Flow's copy of the orders: done when the
|
|
55
|
+
// order has the new status, expired when a day has passed. An order that
|
|
56
|
+
// moved to some other status settles as done too: Seazona has the last word.
|
|
57
|
+
export async function settleStageMoves(db, { now = Date.now() } = {}) {
|
|
58
|
+
const at = new Date(Number(now));
|
|
59
|
+
const waiting = await db.select({ move: seazonaStageMoves, order: seazonaOrders }).from(seazonaStageMoves)
|
|
60
|
+
.leftJoin(seazonaOrders, eq(seazonaOrders.id, seazonaStageMoves.orderId))
|
|
61
|
+
.where(eq(seazonaStageMoves.state, 'waiting'));
|
|
62
|
+
const done = [];
|
|
63
|
+
const expired = [];
|
|
64
|
+
for (const { move, order } of waiting) {
|
|
65
|
+
const status = orderStatusLabel(order) || null;
|
|
66
|
+
if (status && status !== move.fromStatus) done.push(move.orderId);
|
|
67
|
+
else if (move.expiresAt <= at) expired.push(move.orderId);
|
|
68
|
+
}
|
|
69
|
+
if (done.length) {
|
|
70
|
+
await db.update(seazonaStageMoves).set({ state: 'done', settledAt: at })
|
|
71
|
+
.where(and(inArray(seazonaStageMoves.orderId, done), eq(seazonaStageMoves.state, 'waiting')));
|
|
72
|
+
}
|
|
73
|
+
if (expired.length) {
|
|
74
|
+
await db.update(seazonaStageMoves).set({ state: 'expired', settledAt: at })
|
|
75
|
+
.where(and(inArray(seazonaStageMoves.orderId, expired), eq(seazonaStageMoves.state, 'waiting')));
|
|
76
|
+
}
|
|
77
|
+
return { done: done.length, expired: expired.length };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The waiting moves, by order id, for the board's cards.
|
|
81
|
+
export async function waitingStageMoves(db) {
|
|
82
|
+
const rows = await db.select().from(seazonaStageMoves).where(eq(seazonaStageMoves.state, 'waiting'));
|
|
83
|
+
return new Map(rows.map((r) => [r.orderId, r]));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The moves that expired in the last week, for the quiet alert.
|
|
87
|
+
export async function expiredStageMoves(db, { now = Date.now() } = {}) {
|
|
88
|
+
return db.select().from(seazonaStageMoves).where(and(
|
|
89
|
+
eq(seazonaStageMoves.state, 'expired'),
|
|
90
|
+
gte(seazonaStageMoves.settledAt, new Date(Number(now) - EXPIRED_ALERT_MS)),
|
|
91
|
+
lt(seazonaStageMoves.settledAt, new Date(Number(now) + 1)),
|
|
92
|
+
));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// What the board shows for a waiting move.
|
|
96
|
+
export function handOff(row, { orderUrlTemplate = null } = {}) {
|
|
97
|
+
return {
|
|
98
|
+
kind: 'hand_off',
|
|
99
|
+
system: 'seazona',
|
|
100
|
+
sourceJobRef: row.orderId,
|
|
101
|
+
caseRef: row.orderId,
|
|
102
|
+
from: row.fromStatus,
|
|
103
|
+
to: row.toStatus,
|
|
104
|
+
state: row.state,
|
|
105
|
+
requestedAt: row.requestedAt.toISOString(),
|
|
106
|
+
expiresAt: row.expiresAt.toISOString(),
|
|
107
|
+
openUrl: orderUrl(orderUrlTemplate, row.orderId),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Each person's own Seazona choices, with their defaults.
|
|
112
|
+
export const PERSON_SETTINGS = Object.freeze([
|
|
113
|
+
{
|
|
114
|
+
key: 'askBeforeStageMoves',
|
|
115
|
+
label: 'Ask before stage moves',
|
|
116
|
+
hint: 'On the Jobs board, show what to do in Seazona before a card moves to a Seazona stage.',
|
|
117
|
+
kind: 'onoff',
|
|
118
|
+
default: true,
|
|
119
|
+
},
|
|
120
|
+
]);
|
|
121
|
+
|
|
122
|
+
export async function readPersonSettings(db, personId) {
|
|
123
|
+
const [row] = await db.select().from(seazonaPersonSettings).where(eq(seazonaPersonSettings.personId, String(personId)));
|
|
124
|
+
return PERSON_SETTINGS.map((s) => ({ ...s, value: row ? Boolean(row[s.key]) : s.default }));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function writePersonSetting(db, personId, { key, value }, { now = Date.now() } = {}) {
|
|
128
|
+
const setting = PERSON_SETTINGS.find((s) => s.key === key);
|
|
129
|
+
if (!setting) throw new FlowError('invalid', 'no such setting');
|
|
130
|
+
if (typeof value !== 'boolean') throw new FlowError('invalid', `${setting.label} must be on or off`);
|
|
131
|
+
const at = new Date(Number(now));
|
|
132
|
+
await db.insert(seazonaPersonSettings).values({ personId: String(personId), [key]: value, updatedAt: at })
|
|
133
|
+
.onConflictDoUpdate({ target: seazonaPersonSettings.personId, set: { [key]: value, updatedAt: at } });
|
|
134
|
+
return readPersonSettings(db, personId);
|
|
135
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { and, eq, getTableColumns, gt, inArray, isNotNull, isNull, lt, ne, or, sql } from 'drizzle-orm';
|
|
1
|
+
import { and, asc, eq, getTableColumns, gt, inArray, isNotNull, isNull, lt, lte, ne, or, sql } from 'drizzle-orm';
|
|
2
2
|
import { hashOf } from './map.js';
|
|
3
3
|
import {
|
|
4
4
|
seazonaClients,
|
|
@@ -215,11 +215,12 @@ export async function clearRecordFailures(db, resource, keys) {
|
|
|
215
215
|
|
|
216
216
|
// Records one failed read, as recordOrderFailure does for orders. Answers
|
|
217
217
|
// { failureCount, quarantinedUntil }.
|
|
218
|
-
export async function recordRecordFailure(db, resource, key, failure, { at, quarantineFailures, quarantineMs }) {
|
|
218
|
+
export async function recordRecordFailure(db, resource, key, failure, { at, quarantineFailures, quarantineMs, detailId = null }) {
|
|
219
219
|
const until = new Date(at.getTime() + quarantineMs);
|
|
220
220
|
const [row] = await db.insert(seazonaRecordReadFailures).values({
|
|
221
221
|
resource,
|
|
222
222
|
recordKey: key,
|
|
223
|
+
detailId,
|
|
223
224
|
failureCount: 1,
|
|
224
225
|
lastAttemptAt: at,
|
|
225
226
|
lastHttpStatus: failure.httpStatus,
|
|
@@ -229,6 +230,7 @@ export async function recordRecordFailure(db, resource, key, failure, { at, quar
|
|
|
229
230
|
target: [seazonaRecordReadFailures.resource, seazonaRecordReadFailures.recordKey],
|
|
230
231
|
set: {
|
|
231
232
|
failureCount: sql`${seazonaRecordReadFailures.failureCount} + 1`,
|
|
233
|
+
detailId: sql`coalesce(${detailId}::text, ${seazonaRecordReadFailures.detailId})`,
|
|
232
234
|
lastAttemptAt: at,
|
|
233
235
|
lastHttpStatus: failure.httpStatus,
|
|
234
236
|
lastFailureKind: failure.kind,
|
|
@@ -239,6 +241,26 @@ export async function recordRecordFailure(db, resource, key, failure, { at, quar
|
|
|
239
241
|
return { failureCount: row.failureCount, quarantinedUntil: row.quarantinedUntil?.toISOString() ?? null };
|
|
240
242
|
}
|
|
241
243
|
|
|
244
|
+
// Issue #142: quarantined records whose wait has ended, oldest wait first,
|
|
245
|
+
// that the lane can read again by id.
|
|
246
|
+
export async function readRecordsDueForReread(db, resource, { at, limit }) {
|
|
247
|
+
return db.select().from(seazonaRecordReadFailures).where(and(
|
|
248
|
+
eq(seazonaRecordReadFailures.resource, resource),
|
|
249
|
+
isNotNull(seazonaRecordReadFailures.detailId),
|
|
250
|
+
lte(seazonaRecordReadFailures.quarantinedUntil, at),
|
|
251
|
+
)).orderBy(asc(seazonaRecordReadFailures.quarantinedUntil)).limit(limit);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Per resource: quarantined records whose wait has ended, so they wait for a
|
|
255
|
+
// re-read.
|
|
256
|
+
export async function readRecordRereadCounts(db, { at }) {
|
|
257
|
+
const rows = await db.select({ resource: seazonaRecordReadFailures.resource, records: sql`count(*)::int` })
|
|
258
|
+
.from(seazonaRecordReadFailures)
|
|
259
|
+
.where(lte(seazonaRecordReadFailures.quarantinedUntil, at))
|
|
260
|
+
.groupBy(seazonaRecordReadFailures.resource);
|
|
261
|
+
return Object.fromEntries(rows.map((row) => [row.resource, row.records]));
|
|
262
|
+
}
|
|
263
|
+
|
|
242
264
|
export async function readQuarantinedRecordCounts(db, { at }) {
|
|
243
265
|
const rows = await db.select({ resource: seazonaRecordReadFailures.resource, records: sql`count(*)::int` })
|
|
244
266
|
.from(seazonaRecordReadFailures)
|