@arcforgelabs/flow 2026.10.33 → 2026.10.35
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 +7 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
- package/server/api/custom-fields.js +115 -0
- package/server/api/invoicing.js +43 -12
- package/server/api/work-moves.js +80 -0
- package/server/app.js +12 -0
- package/server/auth/people.js +35 -0
- package/server/auth/routes.js +14 -0
- package/server/core/custom-fields.js +308 -0
- package/server/core/job-board.js +4 -0
- package/server/core/ledger.js +82 -2
- package/server/core/tracing.js +64 -0
- package/server/db/migrations/0018_custom_fields.sql +78 -0
- package/server/db/migrations/0019_people_views.sql +1 -0
- package/server/db/migrations/meta/0018_snapshot.json +4289 -0
- package/server/db/migrations/meta/0019_snapshot.json +4295 -0
- package/server/db/migrations/meta/_journal.json +14 -0
- package/server/db/schema.js +64 -1
- package/server/index.js +6 -1
- package/server/integrations/port.js +8 -1
- package/server/integrations/seazona/index.js +60 -2
- package/server/integrations/seazona/migrations/0007_stage_moves.sql +18 -0
- package/server/integrations/seazona/migrations/meta/0007_snapshot.json +1519 -0
- package/server/integrations/seazona/migrations/meta/_journal.json +7 -0
- package/server/integrations/seazona/schema.js +26 -0
- package/server/integrations/seazona/settings.js +35 -0
- package/server/integrations/seazona/stage-moves.js +133 -0
- package/server/integrations/seazona/tables.js +2 -0
- package/server/integrations/sentry/index.js +61 -10
- package/server/release-channel.js +8 -6
- 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 +128 -10
- package/server/ui/invoice-notes.js +1 -1
- package/server/ui/invoicing.js +185 -48
- package/server/ui/job-board.js +1 -1
- package/server/ui/job-change.js +78 -0
- package/server/ui/job-view.js +119 -0
- package/server/ui/jobs.js +482 -49
- package/server/ui/monitoring.js +44 -2
- package/server/ui/routes.js +1 -1
|
@@ -292,3 +292,29 @@ export const seazonaSweepFindings = pgTable('seazona_sweep_findings', {
|
|
|
292
292
|
primaryKey({ columns: [t.resource, t.recordKey, t.kind] }),
|
|
293
293
|
index('seazona_sweep_findings_found').on(t.lastFoundAt),
|
|
294
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,37 @@ export function normaliseBaseUrl(value) {
|
|
|
55
55
|
return `${url.origin}${path}/v1`;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
// SEAZONA_ORDER_URL: the address of one order in Seazona's own web app, with
|
|
59
|
+
// {ref} where the order id goes. Seazona's API guide gives no order page
|
|
60
|
+
// address, and each lab has its own, so a person may also paste the address
|
|
61
|
+
// of any one order as the browser shows it: the order id in it (a GUID)
|
|
62
|
+
// becomes {ref}. Answers the template, or null: https only, and an order id
|
|
63
|
+
// or {ref} in it. It never throws; an address Flow cannot read turns it off.
|
|
64
|
+
const ORDER_ID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
|
|
65
|
+
|
|
66
|
+
export function orderUrlTemplate(value) {
|
|
67
|
+
const given = text(value);
|
|
68
|
+
const raw = given.includes('{ref}') ? given : ORDER_ID.test(given) ? given.replace(ORDER_ID, '{ref}') : '';
|
|
69
|
+
if (!raw) return null;
|
|
70
|
+
try {
|
|
71
|
+
return new URL(raw.replaceAll('{ref}', 'x')).protocol === 'https:' ? raw : null;
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// The link to one order for "Open in Seazona": the order's own page when the
|
|
78
|
+
// installation set SEAZONA_ORDER_URL, else the home page of the lab's
|
|
79
|
+
// Seazona web app when the API address names it (https://{lab}.seazonaapi.net),
|
|
80
|
+
// else null. `exact` is true only for the order's own page.
|
|
81
|
+
export function orderLink(settings, { sourceJobRef } = {}) {
|
|
82
|
+
const template = settings?.orderUrlTemplate ?? null;
|
|
83
|
+
if (template && sourceJobRef) return { url: template.replaceAll('{ref}', encodeURIComponent(sourceJobRef)), exact: true };
|
|
84
|
+
const host = settings?.baseUrl ? new URL(settings.baseUrl).hostname : '';
|
|
85
|
+
const lab = /^([a-z0-9-]+)\.seazonaapi\.net$/i.exec(host)?.[1];
|
|
86
|
+
return lab ? { url: `https://${lab}.seazona.net/`, exact: false } : null;
|
|
87
|
+
}
|
|
88
|
+
|
|
58
89
|
// Returns the resolved settings, or throws SeazonaSettingsError. `mode` is
|
|
59
90
|
// null when SEAZONA_MODE is unset: the adapter is then not configured.
|
|
60
91
|
export function resolveSeazonaSettings(env = process.env) {
|
|
@@ -161,6 +192,10 @@ export function resolveSeazonaSettings(env = process.env) {
|
|
|
161
192
|
rate,
|
|
162
193
|
// The scheduler starts only in live mode (issue #10).
|
|
163
194
|
scheduled: mode === 'live',
|
|
195
|
+
// The address of one order in Seazona's own screens, with {ref} for the
|
|
196
|
+
// order number, for "Open in Seazona" on a stage move. Optional; one
|
|
197
|
+
// without {ref} is ignored.
|
|
198
|
+
orderUrlTemplate: orderUrlTemplate(env.SEAZONA_ORDER_URL),
|
|
164
199
|
};
|
|
165
200
|
|
|
166
201
|
if (mode === 'live' || mode === 'sandbox') {
|
|
@@ -0,0 +1,133 @@
|
|
|
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 { orderLink } from './settings.js';
|
|
5
|
+
import { seazonaOrders, seazonaPersonSettings, seazonaStageMoves } from './schema.js';
|
|
6
|
+
|
|
7
|
+
// Stage moves on the Jobs board (docs/SEAZONA.md, "Stage moves"). Seazona's
|
|
8
|
+
// API cannot change an order's status, so Flow never tries: a move becomes a
|
|
9
|
+
// hand-off. Flow records the move, the person makes it in Seazona, and the
|
|
10
|
+
// next sync that reads the new status settles it. Until then the card shows
|
|
11
|
+
// "Waiting for Seazona" in the new lane. After a day with no change the move
|
|
12
|
+
// expires, the card goes back to Seazona's stage, and a quiet alert says so.
|
|
13
|
+
// Every function here reads and writes Flow's database only.
|
|
14
|
+
|
|
15
|
+
export const HAND_OFF_MS = 24 * 60 * 60_000;
|
|
16
|
+
// How long an expired move stays in the alert.
|
|
17
|
+
const EXPIRED_ALERT_MS = 7 * 24 * 60 * 60_000;
|
|
18
|
+
const MAX_STATUS = 80;
|
|
19
|
+
|
|
20
|
+
// Record a person's move of one order to `toStatus`, and answer the hand-off
|
|
21
|
+
// the board shows. A second move of the same order replaces the first.
|
|
22
|
+
export async function requestStageMove(db, { orderId, toStatus, personId = null, settings = null, now = Date.now() }) {
|
|
23
|
+
const to = typeof toStatus === 'string' ? toStatus.trim() : '';
|
|
24
|
+
if (!to || to.length > MAX_STATUS) throw new FlowError('invalid', 'toStatus must be a Seazona status');
|
|
25
|
+
const [order] = await db.select().from(seazonaOrders).where(eq(seazonaOrders.id, String(orderId ?? '')));
|
|
26
|
+
if (!order || order.deletedInSourceAt) throw new FlowError('not_found', 'Flow has no Seazona order with that number');
|
|
27
|
+
const known = await db.selectDistinct({ status: seazonaOrders.status }).from(seazonaOrders)
|
|
28
|
+
.where(eq(sql`lower(trim(${seazonaOrders.status}))`, to.toLowerCase()));
|
|
29
|
+
if (!known.length) throw new FlowError('invalid', `Seazona has no status named ${to}`);
|
|
30
|
+
const from = orderStatusLabel(order) || null;
|
|
31
|
+
if (from && from.toLowerCase() === to.toLowerCase()) throw new FlowError('conflict', `The order is already ${from} in Seazona`);
|
|
32
|
+
const at = new Date(Number(now));
|
|
33
|
+
const row = {
|
|
34
|
+
orderId: order.id, fromStatus: from, toStatus: known[0].status.trim(), requestedBy: personId,
|
|
35
|
+
requestedAt: at, expiresAt: new Date(at.getTime() + HAND_OFF_MS), state: 'waiting', settledAt: null,
|
|
36
|
+
};
|
|
37
|
+
await db.insert(seazonaStageMoves).values(row).onConflictDoUpdate({ target: seazonaStageMoves.orderId, set: row });
|
|
38
|
+
return handOff(row, { settings });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Take a waiting move back: the card returns to Seazona's stage.
|
|
42
|
+
export async function cancelStageMove(db, { orderId, now = Date.now() }) {
|
|
43
|
+
const rows = await db.update(seazonaStageMoves).set({ state: 'cancelled', settledAt: new Date(Number(now)) })
|
|
44
|
+
.where(and(eq(seazonaStageMoves.orderId, String(orderId ?? '')), eq(seazonaStageMoves.state, 'waiting'))).returning();
|
|
45
|
+
return { cancelled: rows.length > 0 };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Settle the waiting moves against Flow's copy of the orders: done when the
|
|
49
|
+
// order has the new status, expired when a day has passed. An order that
|
|
50
|
+
// moved to some other status settles as done too: Seazona has the last word.
|
|
51
|
+
export async function settleStageMoves(db, { now = Date.now() } = {}) {
|
|
52
|
+
const at = new Date(Number(now));
|
|
53
|
+
const waiting = await db.select({ move: seazonaStageMoves, order: seazonaOrders }).from(seazonaStageMoves)
|
|
54
|
+
.leftJoin(seazonaOrders, eq(seazonaOrders.id, seazonaStageMoves.orderId))
|
|
55
|
+
.where(eq(seazonaStageMoves.state, 'waiting'));
|
|
56
|
+
const done = [];
|
|
57
|
+
const expired = [];
|
|
58
|
+
for (const { move, order } of waiting) {
|
|
59
|
+
const status = orderStatusLabel(order) || null;
|
|
60
|
+
if (status && status !== move.fromStatus) done.push(move.orderId);
|
|
61
|
+
else if (move.expiresAt <= at) expired.push(move.orderId);
|
|
62
|
+
}
|
|
63
|
+
if (done.length) {
|
|
64
|
+
await db.update(seazonaStageMoves).set({ state: 'done', settledAt: at })
|
|
65
|
+
.where(and(inArray(seazonaStageMoves.orderId, done), eq(seazonaStageMoves.state, 'waiting')));
|
|
66
|
+
}
|
|
67
|
+
if (expired.length) {
|
|
68
|
+
await db.update(seazonaStageMoves).set({ state: 'expired', settledAt: at })
|
|
69
|
+
.where(and(inArray(seazonaStageMoves.orderId, expired), eq(seazonaStageMoves.state, 'waiting')));
|
|
70
|
+
}
|
|
71
|
+
return { done: done.length, expired: expired.length };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// The waiting moves, by order id, for the board's cards.
|
|
75
|
+
export async function waitingStageMoves(db) {
|
|
76
|
+
const rows = await db.select().from(seazonaStageMoves).where(eq(seazonaStageMoves.state, 'waiting'));
|
|
77
|
+
return new Map(rows.map((r) => [r.orderId, r]));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The moves that expired in the last week, for the quiet alert.
|
|
81
|
+
export async function expiredStageMoves(db, { now = Date.now() } = {}) {
|
|
82
|
+
return db.select().from(seazonaStageMoves).where(and(
|
|
83
|
+
eq(seazonaStageMoves.state, 'expired'),
|
|
84
|
+
gte(seazonaStageMoves.settledAt, new Date(Number(now) - EXPIRED_ALERT_MS)),
|
|
85
|
+
lt(seazonaStageMoves.settledAt, new Date(Number(now) + 1)),
|
|
86
|
+
));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// What the board shows for a waiting move. `link` opens the order in
|
|
90
|
+
// Seazona's web app (settings.js, orderLink); `exact` is false when it opens
|
|
91
|
+
// only the lab's Seazona home page.
|
|
92
|
+
export function handOff(row, { settings = null } = {}) {
|
|
93
|
+
const link = orderLink(settings, { sourceJobRef: row.orderId });
|
|
94
|
+
return {
|
|
95
|
+
kind: 'hand_off',
|
|
96
|
+
system: 'seazona',
|
|
97
|
+
sourceJobRef: row.orderId,
|
|
98
|
+
caseRef: row.orderId,
|
|
99
|
+
from: row.fromStatus,
|
|
100
|
+
to: row.toStatus,
|
|
101
|
+
state: row.state,
|
|
102
|
+
requestedAt: row.requestedAt.toISOString(),
|
|
103
|
+
expiresAt: row.expiresAt.toISOString(),
|
|
104
|
+
openUrl: link?.url ?? null,
|
|
105
|
+
openExact: Boolean(link?.exact),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Each person's own Seazona choices, with their defaults.
|
|
110
|
+
export const PERSON_SETTINGS = Object.freeze([
|
|
111
|
+
{
|
|
112
|
+
key: 'askBeforeStageMoves',
|
|
113
|
+
label: 'Ask before stage moves',
|
|
114
|
+
hint: 'On the Jobs board, show what to do in Seazona before a card moves to a Seazona stage.',
|
|
115
|
+
kind: 'onoff',
|
|
116
|
+
default: true,
|
|
117
|
+
},
|
|
118
|
+
]);
|
|
119
|
+
|
|
120
|
+
export async function readPersonSettings(db, personId) {
|
|
121
|
+
const [row] = await db.select().from(seazonaPersonSettings).where(eq(seazonaPersonSettings.personId, String(personId)));
|
|
122
|
+
return PERSON_SETTINGS.map((s) => ({ ...s, value: row ? Boolean(row[s.key]) : s.default }));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function writePersonSetting(db, personId, { key, value }, { now = Date.now() } = {}) {
|
|
126
|
+
const setting = PERSON_SETTINGS.find((s) => s.key === key);
|
|
127
|
+
if (!setting) throw new FlowError('invalid', 'no such setting');
|
|
128
|
+
if (typeof value !== 'boolean') throw new FlowError('invalid', `${setting.label} must be on or off`);
|
|
129
|
+
const at = new Date(Number(now));
|
|
130
|
+
await db.insert(seazonaPersonSettings).values({ personId: String(personId), [key]: value, updatedAt: at })
|
|
131
|
+
.onConflictDoUpdate({ target: seazonaPersonSettings.personId, set: { [key]: value, updatedAt: at } });
|
|
132
|
+
return readPersonSettings(db, personId);
|
|
133
|
+
}
|
|
@@ -1,16 +1,27 @@
|
|
|
1
1
|
import * as SentryNode from '@sentry/node';
|
|
2
|
+
import { scrubSpan } from '../../ui/monitoring.js';
|
|
2
3
|
|
|
3
|
-
// The Sentry adapter (docs/SENTRY.md). It sends the errors Flow logs,
|
|
4
|
-
// browser's errors and problem reports,
|
|
4
|
+
// The Sentry adapter (docs/SENTRY.md). It sends the errors Flow logs, the
|
|
5
|
+
// browser's errors and problem reports, and performance traces from both
|
|
6
|
+
// (when SENTRY_TRACES_SAMPLE_RATE is above 0), to the installation's own Sentry
|
|
5
7
|
// project. Flow ships no DSN: each installation enters its own, here or as
|
|
6
8
|
// the host setting SENTRY_DSN. It has no tables.
|
|
7
9
|
|
|
8
10
|
// The browser SDK, loaded from Sentry's CDN only while this adapter is on
|
|
9
|
-
// (server/ui/monitoring.js). Pinned, with its integrity hash.
|
|
11
|
+
// (server/ui/monitoring.js). Pinned, with its integrity hash. With traces on,
|
|
12
|
+
// the bundle that also traces; Sentry ships it only with session replay in
|
|
13
|
+
// it too, which Flow never turns on.
|
|
10
14
|
export const SENTRY_BROWSER = Object.freeze({
|
|
11
15
|
src: 'https://browser.sentry-cdn.com/11.4.0/bundle.feedback.min.js',
|
|
12
16
|
integrity: 'sha384-H9RmZ/VaqOPZrUezgCSqHp4t3LoQMd/5+0Y4oEGUB2bddZzSnwZiOYoWneK4diWt',
|
|
13
17
|
});
|
|
18
|
+
export const SENTRY_BROWSER_TRACING = Object.freeze({
|
|
19
|
+
src: 'https://browser.sentry-cdn.com/11.4.0/bundle.tracing.replay.feedback.min.js',
|
|
20
|
+
integrity: 'sha384-LjwHQKAojcITl6Q7innDSAFjziFnqV9ovZBNXnXnQwub1VXIdBMJnYrQNihtxSa6',
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// The share of requests and page loads traced when the setting is empty.
|
|
24
|
+
export const DEFAULT_TRACES_SAMPLE_RATE = 0.2;
|
|
14
25
|
|
|
15
26
|
// A DSN is https://<public key>@<host>/<project id>.
|
|
16
27
|
const DSN_PATTERN = /^https:\/\/[0-9a-f]{16,64}@[a-z0-9.-]+(:\d+)?\/(\d+)$/i;
|
|
@@ -31,6 +42,7 @@ export const SENTRY_SETUP = {
|
|
|
31
42
|
{ name: 'SENTRY_DSN', label: 'DSN', kind: 'url', help: 'From the Sentry project\'s Client Keys. Browsers see it, so it is not a secret.' },
|
|
32
43
|
{ name: 'SENTRY_ENVIRONMENT', label: 'Environment', help: 'How Sentry labels this installation, for example production. Empty: production.' },
|
|
33
44
|
{ name: 'SENTRY_FEEDBACK', label: 'Report a problem button', kind: 'onoff', help: 'On: everyone signed in can send a report, with a screenshot, to this Sentry project.' },
|
|
45
|
+
{ name: 'SENTRY_TRACES_SAMPLE_RATE', label: 'Traces', help: 'The share of clicks, page loads and requests timed, from 0 (off) to 1 (all). Empty: 0.2.' },
|
|
34
46
|
],
|
|
35
47
|
};
|
|
36
48
|
|
|
@@ -40,9 +52,17 @@ export function readSentrySettings(env) {
|
|
|
40
52
|
if (!DSN_PATTERN.test(dsn)) throw new Error('SENTRY_DSN is not a Sentry DSN (https://<key>@<host>/<project id>).');
|
|
41
53
|
const environment = String(env.SENTRY_ENVIRONMENT ?? '').trim() || 'production';
|
|
42
54
|
const feedback = String(env.SENTRY_FEEDBACK ?? 'on').trim() !== 'off';
|
|
43
|
-
|
|
55
|
+
const rate = String(env.SENTRY_TRACES_SAMPLE_RATE ?? '').trim();
|
|
56
|
+
const tracesSampleRate = rate === '' ? DEFAULT_TRACES_SAMPLE_RATE : Number(rate);
|
|
57
|
+
if (!/^\d*\.?\d+$/.test(rate || '0') || !(tracesSampleRate >= 0 && tracesSampleRate <= 1)) {
|
|
58
|
+
throw new Error('SENTRY_TRACES_SAMPLE_RATE must be a number from 0 to 1, such as 0.2.');
|
|
59
|
+
}
|
|
60
|
+
return { dsn, environment, feedback, tracesSampleRate };
|
|
44
61
|
}
|
|
45
62
|
|
|
63
|
+
// No trace for the host's health check: it is not a person waiting.
|
|
64
|
+
const UNTRACED_PATHS = new Set(['/health']);
|
|
65
|
+
|
|
46
66
|
// The release Sentry groups events by: Flow's release tag, else its commit.
|
|
47
67
|
export function sentryRelease(env) {
|
|
48
68
|
const read = (value) => String(value ?? '').trim() || null;
|
|
@@ -51,7 +71,7 @@ export function sentryRelease(env) {
|
|
|
51
71
|
return release ? `flow@${release.replace(/^v/, '')}` : revision ? `flow@${revision.slice(0, 12)}` : null;
|
|
52
72
|
}
|
|
53
73
|
|
|
54
|
-
// `sdk` is for tests: anything with init,
|
|
74
|
+
// `sdk` is for tests: anything with init, captureEvent, captureException
|
|
55
75
|
// and close, as @sentry/node has.
|
|
56
76
|
export function createSentryAdapter({ sdk = SentryNode, env: hostEnv = process.env } = {}) {
|
|
57
77
|
let settings = null;
|
|
@@ -71,10 +91,14 @@ export function createSentryAdapter({ sdk = SentryNode, env: hostEnv = process.e
|
|
|
71
91
|
release: release ?? undefined,
|
|
72
92
|
// Flow sends what it logs; no request bodies, cookies or IP addresses.
|
|
73
93
|
sendDefaultPii: false,
|
|
74
|
-
|
|
75
|
-
|
|
94
|
+
// A request the browser traced keeps the browser's choice, so a
|
|
95
|
+
// click and the server work it caused stay one trace.
|
|
96
|
+
tracesSampler: ({ attributes, inheritOrSampleWith }) => (
|
|
97
|
+
UNTRACED_PATHS.has(attributes?.['url.path']) ? 0 : inheritOrSampleWith(settings.tracesSampleRate)
|
|
98
|
+
),
|
|
99
|
+
beforeSendSpan: scrubSpan,
|
|
76
100
|
});
|
|
77
|
-
log.info?.(`[sentry] on: environment ${settings.environment}; report button ${settings.feedback ? 'on' : 'off'}`);
|
|
101
|
+
log.info?.(`[sentry] on: environment ${settings.environment}; report button ${settings.feedback ? 'on' : 'off'}; traces ${settings.tracesSampleRate > 0 ? settings.tracesSampleRate : 'off'}`);
|
|
78
102
|
},
|
|
79
103
|
async stop() {
|
|
80
104
|
if (settings) await sdk.close(2000);
|
|
@@ -90,13 +114,40 @@ export function createSentryAdapter({ sdk = SentryNode, env: hostEnv = process.e
|
|
|
90
114
|
if (!settings) return;
|
|
91
115
|
const scope = { level: 'error', tags: { source } };
|
|
92
116
|
if (error instanceof Error) sdk.captureException(error, { ...scope, extra: { log: message } });
|
|
93
|
-
|
|
117
|
+
// Not captureMessage: the SDK adds a stack to it, and Sentry then
|
|
118
|
+
// names the issue after its top frame ("Object.captureError"), not
|
|
119
|
+
// the line Flow logged. A plain event keeps the line as the title.
|
|
120
|
+
else sdk.captureEvent({ message, ...scope });
|
|
94
121
|
},
|
|
95
122
|
// What the browser needs to report to the same project. The DSN is
|
|
96
123
|
// meant for browsers.
|
|
97
124
|
browserMonitoring() {
|
|
98
125
|
if (!settings) return null;
|
|
99
|
-
|
|
126
|
+
const { tracesSampleRate } = settings;
|
|
127
|
+
return {
|
|
128
|
+
dsn: settings.dsn,
|
|
129
|
+
environment: settings.environment,
|
|
130
|
+
release,
|
|
131
|
+
feedback: settings.feedback,
|
|
132
|
+
tracesSampleRate,
|
|
133
|
+
script: tracesSampleRate > 0 ? SENTRY_BROWSER_TRACING : SENTRY_BROWSER,
|
|
134
|
+
};
|
|
135
|
+
},
|
|
136
|
+
// Runs `work` in a span of the current trace (server/core/tracing.js).
|
|
137
|
+
// Work outside a traced request (the pipeline, the workers) starts no
|
|
138
|
+
// trace of its own.
|
|
139
|
+
traceSpan({ name, op, attributes }, work) {
|
|
140
|
+
if (!settings?.tracesSampleRate) return work();
|
|
141
|
+
return sdk.startSpan({ name, op, attributes, onlyIfParent: true }, work);
|
|
142
|
+
},
|
|
143
|
+
// At a request's start: a function that names its trace once the route
|
|
144
|
+
// is known, or null when this request is not traced.
|
|
145
|
+
traceRequest() {
|
|
146
|
+
if (!settings?.tracesSampleRate) return null;
|
|
147
|
+
const span = sdk.getActiveSpan();
|
|
148
|
+
if (!span) return null;
|
|
149
|
+
const root = sdk.getRootSpan(span);
|
|
150
|
+
return (name) => sdk.updateSpanName(root, name);
|
|
100
151
|
},
|
|
101
152
|
},
|
|
102
153
|
};
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
// GitHub Releases (with npm's dist-tags as the fallback). Used by the Replit
|
|
3
3
|
// deploy (scripts/deploy-release.js, FLOW_DEPLOY_REF=stable|beta).
|
|
4
4
|
//
|
|
5
|
-
// Release names follow docs/RELEASING.md: vYYYY.M.N is stable,
|
|
6
|
-
// vYYYY.M.N-beta.B is a beta of the upcoming YYYY.M.N.
|
|
5
|
+
// Release names follow docs/RELEASING.md: vYYYY.M.N is stable,
|
|
6
|
+
// vYYYY.M.N-beta.B is a beta of the upcoming YYYY.M.N, and vYYYY.M.N-C is a
|
|
7
|
+
// stable correction of YYYY.M.N.
|
|
7
8
|
//
|
|
8
9
|
// stable GitHub's latest release (what Flow Release Promote points at, so
|
|
9
10
|
// a rollback is followed), else npm `latest`
|
|
@@ -13,7 +14,7 @@
|
|
|
13
14
|
// Nothing here sends anything about the installation: each request is an
|
|
14
15
|
// anonymous GET of public data.
|
|
15
16
|
|
|
16
|
-
const TAG = /^v(20\d{2})\.([1-9]|1[0-2])\.([1-9]\d*)(?:-beta\.([1-9]\d*))?$/;
|
|
17
|
+
const TAG = /^v(20\d{2})\.([1-9]|1[0-2])\.([1-9]\d*)(?:-beta\.([1-9]\d*)|-([1-9]\d*))?$/;
|
|
17
18
|
export const CHANNELS = ["stable", "beta"];
|
|
18
19
|
export const DEFAULT_GITHUB_API = "https://api.github.com";
|
|
19
20
|
export const DEFAULT_GITHUB_DOWNLOADS = "https://github.com";
|
|
@@ -35,8 +36,9 @@ export function parseReleaseTag(value) {
|
|
|
35
36
|
tag,
|
|
36
37
|
version: tag.slice(1),
|
|
37
38
|
channel: beta ? "beta" : "stable",
|
|
38
|
-
// A stable release sorts after every beta of its own version
|
|
39
|
-
|
|
39
|
+
// A stable release sorts after every beta of its own version, and its
|
|
40
|
+
// corrections after it.
|
|
41
|
+
parts: [Number(match[1]), Number(match[2]), Number(match[3]), beta ?? Number.POSITIVE_INFINITY, Number(match[5] ?? 0)],
|
|
40
42
|
};
|
|
41
43
|
}
|
|
42
44
|
|
|
@@ -44,7 +46,7 @@ export function parseReleaseTag(value) {
|
|
|
44
46
|
export function compareReleases(a, b) {
|
|
45
47
|
const [x, y] = [parseReleaseTag(a), parseReleaseTag(b)];
|
|
46
48
|
if (!x || !y) throw new ReleaseUnavailable(`cannot compare ${a} with ${b}`);
|
|
47
|
-
for (let index = 0; index <
|
|
49
|
+
for (let index = 0; index < 5; index += 1) {
|
|
48
50
|
if (x.parts[index] !== y.parts[index]) return x.parts[index] < y.parts[index] ? -1 : 1;
|
|
49
51
|
}
|
|
50
52
|
return 0;
|
package/server/ui/app-page.js
CHANGED
|
@@ -51,7 +51,7 @@ const BODY = `<div class="fx-app">
|
|
|
51
51
|
<div id="toasts" class="fx-toasts" aria-live="polite"></div>
|
|
52
52
|
</div>`;
|
|
53
53
|
|
|
54
|
-
export const APP_PATHS = ['/sign-in', '/jobs', '/invoices', '/invoices/:id', '/people', '/integrations', '/integrations/:id', '/account', '/appearance', '/invoicing-settings', '/price-list'];
|
|
54
|
+
export const APP_PATHS = ['/sign-in', '/jobs', '/invoices', '/invoices/:id', '/people', '/integrations', '/integrations/:id', '/account', '/appearance', '/invoicing-settings', '/price-list', '/custom-fields'];
|
|
55
55
|
|
|
56
56
|
export function appPage({ defaultFamily }) {
|
|
57
57
|
return renderPage({
|
package/server/ui/app.js
CHANGED
|
@@ -13,6 +13,7 @@ import { renderInvoice, renderInvoiceList } from '/assets/ui/invoicing.js';
|
|
|
13
13
|
import { automationSection } from '/assets/ui/automations.js';
|
|
14
14
|
import { renderInvoicingSettings, sendModeSection } from '/assets/ui/invoicing-settings.js';
|
|
15
15
|
import { renderPriceList } from '/assets/ui/price-list.js';
|
|
16
|
+
import { renderCustomFields } from '/assets/ui/custom-fields.js';
|
|
16
17
|
import { renderJobs } from '/assets/ui/jobs.js';
|
|
17
18
|
import { startMonitoring } from '/assets/ui/monitoring.js';
|
|
18
19
|
import { openSearch } from '/assets/ui/search.js';
|
|
@@ -198,6 +199,7 @@ const VIEWS = {
|
|
|
198
199
|
invoices: { label: 'Invoices', icon: 'invoices', group: 'Work', render: (main, id) => (id ? renderInvoice(main, ui(), id) : renderInvoiceList(main, ui())) },
|
|
199
200
|
'invoicing-settings': { label: 'Invoice rules', icon: 'invoices', group: 'Settings', render: (main) => renderInvoicingSettings(main, ui()) },
|
|
200
201
|
'price-list': { label: 'Price list', icon: 'tag', group: 'Settings', render: (main) => renderPriceList(main, ui()) },
|
|
202
|
+
'custom-fields': { label: 'Custom fields', icon: 'fields', group: 'Settings', render: (main) => renderCustomFields(main, ui()) },
|
|
201
203
|
people: { label: 'People', icon: 'people', group: 'Settings', ownerOnly: true, render: renderPeople },
|
|
202
204
|
integrations: { label: 'Integrations', icon: 'plug', group: 'Settings', ownerOnly: true, render: (main, id) => (id ? renderIntegration(main, id) : renderIntegrations(main)) },
|
|
203
205
|
// Not in the sidebar: "Manage account" in the account menu opens the same
|
|
@@ -212,6 +214,7 @@ const ICON_PATHS = {
|
|
|
212
214
|
invoices: '<path d="M6 3h9l4 4v14H6z"/><path d="M14 3v5h5M9 13h7M9 17h5"/>',
|
|
213
215
|
people: '<circle cx="9" cy="8" r="3"/><path d="M3 20c0-3.5 2.7-6 6-6s6 2.5 6 6"/><path d="M16 5a3 3 0 010 6M21 20c0-3-1.6-5-4-5.7"/>',
|
|
214
216
|
tag: '<path d="M3 12V4h8l10 10-8 8z"/><circle cx="7.5" cy="8.5" r="1.5"/>',
|
|
217
|
+
fields: '<rect x="3" y="4" width="18" height="5" rx="1.5"/><rect x="3" y="13" width="18" height="5" rx="1.5"/><path d="M7 6.5h4M7 15.5h7"/>',
|
|
215
218
|
plug: '<path d="M9 3v5M15 3v5M6 8h12v3a6 6 0 01-12 0zM12 17v4"/>',
|
|
216
219
|
person: '<circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 3.6-7 8-7s8 3 8 7"/>',
|
|
217
220
|
flag: '<path d="M5 21V4M5 4h11l-2 4 2 4H5"/>',
|
|
@@ -309,11 +312,15 @@ function reportMenuItem(openReport) {
|
|
|
309
312
|
};
|
|
310
313
|
}
|
|
311
314
|
|
|
315
|
+
// The page a person lands on after sign-in, and for / or an unknown path.
|
|
316
|
+
// Invoices opens on its Ready tab. A home page can take this over later.
|
|
317
|
+
const START_VIEW = 'invoices';
|
|
318
|
+
|
|
312
319
|
// The view and, for one record, its id: /invoices/<id> is ['invoices', '<id>'].
|
|
313
320
|
function currentView() {
|
|
314
321
|
const [name, id = null] = location.pathname.replace(/^\/+|\/+$/g, '').split('/');
|
|
315
322
|
if (VIEWS[name] && (!VIEWS[name].ownerOnly || me?.person.role === 'owner')) return [name, id];
|
|
316
|
-
return [
|
|
323
|
+
return [START_VIEW, null];
|
|
317
324
|
}
|
|
318
325
|
|
|
319
326
|
async function route() {
|
|
@@ -533,7 +540,7 @@ const STATE_TONE = { ready: 'success', disabled: 'mute', failed: 'danger', resta
|
|
|
533
540
|
const CATALOGUE = {
|
|
534
541
|
xero: { maker: 'Xero', category: 'accounting', blurb: 'Send invoices to Xero as drafts and bring payments back.', logo: 'xero.svg' },
|
|
535
542
|
seazona: { maker: 'Seazona', category: 'lab', blurb: 'Bring lab cases and orders into Flow as jobs.', logo: 'seazona.svg' },
|
|
536
|
-
sentry: { maker: 'Sentry', category: 'monitoring', blurb: 'Send errors
|
|
543
|
+
sentry: { maker: 'Sentry', category: 'monitoring', blurb: 'Send errors, problem reports and speed traces to your own Sentry project.', logo: 'sentry.svg' },
|
|
537
544
|
};
|
|
538
545
|
const CATEGORIES = [['all', 'All'], ['accounting', 'Accounting'], ['lab', 'Lab software'], ['monitoring', 'Monitoring']];
|
|
539
546
|
const CATEGORY_LABEL = Object.fromEntries(CATEGORIES);
|
|
@@ -774,6 +781,7 @@ async function renderIntegration(main, id) {
|
|
|
774
781
|
current.state === 'failed' && current.error ? notice(current.error, 'danger', 'It could not start.') : null,
|
|
775
782
|
current.id === 'xero' && current.state === 'ready' ? h('section', { class: 'fx-int-section' }, xeroConnection(body.organisations, canChange)) : null,
|
|
776
783
|
current.id === 'seazona' ? h('section', { class: 'fx-int-section fx-card', style: { padding: '8px 20px 16px' } }, automationSection(ui(), 'seazona', 'Ready to invoice')) : null,
|
|
784
|
+
on && current.state === 'ready' ? personSettingsSection(current) : null,
|
|
777
785
|
current.id === 'xero' ? h('section', { class: 'fx-int-section fx-card', style: { padding: '8px 20px 16px' } }, sendModeSection(ui())) : null,
|
|
778
786
|
guide ? h('section', { class: 'fx-int-section' },
|
|
779
787
|
h('h2', { class: 'fx-int-section__title' }, 'Set up'),
|
|
@@ -798,6 +806,36 @@ async function renderIntegration(main, id) {
|
|
|
798
806
|
draw(found);
|
|
799
807
|
}
|
|
800
808
|
|
|
809
|
+
// "Your settings": this person's own choices in one integration
|
|
810
|
+
// (GET /api/work/my-settings), such as Seazona's "Ask before stage moves".
|
|
811
|
+
// Each person sees and changes only their own.
|
|
812
|
+
function personSettingsSection(integration) {
|
|
813
|
+
const section = h('section', { class: 'fx-int-section' },
|
|
814
|
+
h('h2', { class: 'fx-int-section__title' }, 'Your settings'), h('div', { class: 'fx-skeleton' }));
|
|
815
|
+
flow(`/api/work/my-settings?integration=${encodeURIComponent(integration.id)}`).then(({ integrations }) => {
|
|
816
|
+
const settings = integrations[0]?.settings ?? [];
|
|
817
|
+
if (!settings.length) { section.remove(); return; }
|
|
818
|
+
const rows = settings.map((setting) => {
|
|
819
|
+
const input = h('select', { class: 'fx-select' },
|
|
820
|
+
[{ value: 'off', label: 'Off' }, { value: 'on', label: 'On' }].map((c) => h('option', { value: c.value, selected: (setting.value ? 'on' : 'off') === c.value }, c.label)));
|
|
821
|
+
input.addEventListener('change', async () => {
|
|
822
|
+
input.disabled = true;
|
|
823
|
+
try {
|
|
824
|
+
await flow('/api/work/my-settings', { method: 'PUT', body: JSON.stringify({ integration: integration.id, key: setting.key, value: input.value === 'on' }) });
|
|
825
|
+
toast('Saved for you.', 'success');
|
|
826
|
+
} catch (error) {
|
|
827
|
+
input.value = setting.value ? 'on' : 'off';
|
|
828
|
+
toast(error.message, 'danger');
|
|
829
|
+
}
|
|
830
|
+
input.disabled = false;
|
|
831
|
+
});
|
|
832
|
+
return settingRow({ label: setting.label, hint: setting.hint, control: input });
|
|
833
|
+
});
|
|
834
|
+
section.replaceChildren(section.firstChild, h('div', { class: 'fx-settings' }, rows));
|
|
835
|
+
}).catch(() => section.remove());
|
|
836
|
+
return section;
|
|
837
|
+
}
|
|
838
|
+
|
|
801
839
|
// One setting, laid out the same on every settings screen: a short name, one
|
|
802
840
|
// line of smaller text under it, and the control on the right. Longer help
|
|
803
841
|
// lives behind a link, not here. A wide control (a grid of choices) sits on
|