@arcforgelabs/flow 2026.10.33 → 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.
Files changed (33) hide show
  1. package/README.md +6 -0
  2. package/npm-shrinkwrap.json +2 -2
  3. package/package.json +1 -1
  4. package/server/api/custom-fields.js +114 -0
  5. package/server/api/invoicing.js +2 -1
  6. package/server/api/work-moves.js +80 -0
  7. package/server/app.js +12 -0
  8. package/server/core/custom-fields.js +300 -0
  9. package/server/core/job-board.js +2 -0
  10. package/server/core/tracing.js +64 -0
  11. package/server/db/migrations/0018_custom_fields.sql +78 -0
  12. package/server/db/migrations/meta/0018_snapshot.json +4289 -0
  13. package/server/db/migrations/meta/_journal.json +7 -0
  14. package/server/db/schema.js +59 -0
  15. package/server/index.js +6 -1
  16. package/server/integrations/port.js +7 -1
  17. package/server/integrations/seazona/index.js +47 -1
  18. package/server/integrations/seazona/migrations/0007_stage_moves.sql +18 -0
  19. package/server/integrations/seazona/migrations/meta/0007_snapshot.json +1519 -0
  20. package/server/integrations/seazona/migrations/meta/_journal.json +7 -0
  21. package/server/integrations/seazona/schema.js +26 -0
  22. package/server/integrations/seazona/settings.js +14 -0
  23. package/server/integrations/seazona/stage-moves.js +135 -0
  24. package/server/integrations/seazona/tables.js +2 -0
  25. package/server/integrations/sentry/index.js +61 -10
  26. package/server/ui/app-page.js +1 -1
  27. package/server/ui/app.js +40 -2
  28. package/server/ui/custom-fields.js +190 -0
  29. package/server/ui/flow.css +48 -9
  30. package/server/ui/invoicing.js +83 -5
  31. package/server/ui/jobs.js +3 -1
  32. package/server/ui/monitoring.js +44 -2
  33. package/server/ui/routes.js +1 -1
package/README.md CHANGED
@@ -31,6 +31,9 @@ run a lab.
31
31
  | `CHANGELOG.md` | What changed in each release: an index of one file per release in `CHANGELOG/` |
32
32
  | `SECURITY.md` | How to report a security problem, the trust model, and the rules held hard |
33
33
  | `LICENSE` | MIT licence |
34
+ | `docs/docs.json` | The docs site navigation. `docs/` is published at https://arcforgelabs.github.io/docs/flow/ by arcforgelabs/docs |
35
+ | `docs/index.md` | The docs site home page |
36
+ | `docs/releases/` | One release notes page per version (`<version>.md`), shown on the docs site |
34
37
  | `docs/DESIGN.md` | The design systems (theme families), how pages and Clerk's components take them, and the screens |
35
38
  | `docs/DATA_MODEL.md` | The PostgreSQL records, the rules the database holds, and how to change the schema |
36
39
  | `docs/DATA_SPINE.md` | The design of the tables and jobs every integration shares: mirror, identity map, job queue, outbox, review queue |
@@ -42,7 +45,10 @@ run a lab.
42
45
  | `docs/ALERTS.md` | Quiet alerts: the slim line on a page when something there needs a person, and how a person dismisses it |
43
46
  | `docs/AUTOMATIONS.md` | Automations: Flow routines that mark jobs ready to invoice and draft them, each on or off |
44
47
  | `docs/LINE_MAPPING.md` | Line mapping: how each work system line finds its price item, and Price codes in Settings |
48
+ | `docs/CUSTOM_FIELDS.md` | Custom fields: the lab's own fields for its jobs, kept in Flow only, and how a board move checks for conflicts |
49
+ | `docs/JOB_BOARD_MOVES.md` | Moving jobs on the Jobs board: custom field moves stay in Flow; Seazona stage moves become a hand-off |
45
50
  | `docs/PRICE_LIST.md` | The price list: Flow's own codes and prices, their links to Xero items and Seazona products, and the price check |
51
+ | `docs/LOW_RISK_MERGE.md` | The low-risk merge lane: which pull requests merge themselves when green, with ClawSweeper's risk labels |
46
52
  | `docs/RELEASING.md` | Versions and channels, the release workflows, how to cut and roll back a release, and the one-time setup |
47
53
  | `docs/SELF_HOSTING.md` | Running a Flow release with Docker, Node or Replit, the update notice, and Clerk sign-in |
48
54
  | `docs/REPLIT.md` | A Replit workspace of this repository that follows a release channel (`FLOW_DEPLOY_REF`) |
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.33",
3
+ "version": "2026.10.34",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@arcforgelabs/flow",
9
- "version": "2026.10.33",
9
+ "version": "2026.10.34",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@clerk/backend": "3.23.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.33",
3
+ "version": "2026.10.34",
4
4
  "description": "Invoicing, bill entry and a financial dashboard for labs, with Seazona and Xero integrations.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -0,0 +1,114 @@
1
+ import express from 'express';
2
+ import {
3
+ CUSTOM_FIELD_TYPES, GROUPABLE_TYPES, OPTION_COLORS, archiveField, createField, jobFieldHistory, listFields,
4
+ listJobFieldValues, orderFields, setJobFieldValue, updateField,
5
+ } from '../core/custom-fields.js';
6
+ import { FlowError } from '../core/errors.js';
7
+ import { resolveOrganisation } from './invoicing.js';
8
+
9
+ // Custom fields for jobs (server/core/custom-fields.js, docs/CUSTOM_FIELDS.md).
10
+ //
11
+ // GET /api/custom-fields[?archived=1] the lab's fields, and the work systems' own fields (read only)
12
+ // POST /api/custom-fields { name, type[, options] } owner or admin: make a field
13
+ // PUT /api/custom-fields/order { ids } owner or admin: the field order
14
+ // PUT /api/custom-fields/:id { name?, options? } owner or admin: rename, change options
15
+ // POST /api/custom-fields/:id/archive { archived } owner or admin: archive or bring back
16
+ // GET /api/custom-fields/values every job's values
17
+ // PUT /api/custom-fields/values { sourceSystem, sourceJobRef, fieldId, value[, expectedUpdatedAt] }
18
+ // set one job's value; a stale expectedUpdatedAt gives 409 with the current value
19
+ // GET /api/custom-fields/history?sourceSystem=&sourceJobRef= one job's changes, newest first
20
+ //
21
+ // Every route also takes `organisationId` (query or body) when the
22
+ // installation has more than one organisation. A value lives only in Flow:
23
+ // no route here writes to an integration.
24
+ //
25
+ // `work()` is the work system adapters that offer listWork; their job
26
+ // statuses show as a read-only Stage field.
27
+ const ERROR_STATUS = { not_found: 404, conflict: 409, organisation_required: 409 };
28
+
29
+ export function createCustomFieldsRouter({ db, personAuth, work = () => [], log = console }) {
30
+ const router = express.Router();
31
+ router.use(express.json({ limit: '32kb' }));
32
+ const signedIn = [personAuth.requireSession];
33
+ const manager = [...signedIn, personAuth.requireRole('owner', 'admin')];
34
+
35
+ const handle = (fn) => async (req, res) => {
36
+ try {
37
+ res.set('Cache-Control', 'no-store');
38
+ await fn(req, res);
39
+ } catch (error) {
40
+ if (!(error instanceof FlowError)) {
41
+ log.error?.(`[custom-fields] ${error.message}`);
42
+ return res.status(500).json({ ok: false, code: 'internal', error: 'Something went wrong.' });
43
+ }
44
+ const body = { ok: false, code: error.code, error: error.message };
45
+ if (error.details !== undefined) body.details = error.details;
46
+ return res.status(ERROR_STATUS[error.code] ?? 400).json(body);
47
+ }
48
+ };
49
+ const organisationOf = (req) => resolveOrganisation(db, req.body?.organisationId ?? req.query.organisationId);
50
+
51
+ // The work systems' own fields: their job statuses, as the Stage lanes on
52
+ // the Jobs board. Read from Flow's copy; a work system that fails to answer
53
+ // is left out, not an error.
54
+ async function systemFields() {
55
+ const fields = [];
56
+ for (const adapter of work()) {
57
+ try {
58
+ const { statuses = [] } = await adapter.capabilities.listWork();
59
+ fields.push({
60
+ id: `${adapter.id}:stage`, source: adapter.id, sourceLabel: adapter.name ?? adapter.id, name: 'Stage',
61
+ type: 'dropdown', readOnly: true, options: statuses.map((s) => ({ id: s.label, name: s.label, final: Boolean(s.final) })),
62
+ });
63
+ } catch (error) {
64
+ log.warn?.(`[custom-fields] ${adapter.id} stages: ${error.message}`);
65
+ }
66
+ }
67
+ return fields;
68
+ }
69
+
70
+ router.get('/', ...signedIn, handle(async (req, res) => {
71
+ const organisationId = await organisationOf(req);
72
+ const archived = req.query.archived === '1' || req.query.archived === 'true';
73
+ const [fields, system] = await Promise.all([listFields(db, organisationId, { archived }), systemFields()]);
74
+ res.json({ ok: true, fields, systemFields: system, types: CUSTOM_FIELD_TYPES, groupable: GROUPABLE_TYPES, colors: OPTION_COLORS });
75
+ }));
76
+
77
+ router.post('/', ...manager, handle(async (req, res) => {
78
+ const field = await createField(db, await organisationOf(req), req.body, { personId: req.person.id });
79
+ res.status(201).json({ ok: true, field });
80
+ }));
81
+
82
+ router.put('/order', ...manager, handle(async (req, res) => {
83
+ const fields = await orderFields(db, await organisationOf(req), req.body?.ids, { personId: req.person.id });
84
+ res.json({ ok: true, fields });
85
+ }));
86
+
87
+ router.get('/values', ...signedIn, handle(async (req, res) => {
88
+ res.json({ ok: true, values: await listJobFieldValues(db, await organisationOf(req)) });
89
+ }));
90
+
91
+ router.put('/values', ...signedIn, handle(async (req, res) => {
92
+ const value = await setJobFieldValue(db, await organisationOf(req), req.body, { personId: req.person.id });
93
+ res.json({ ok: true, value });
94
+ }));
95
+
96
+ router.get('/history', ...signedIn, handle(async (req, res) => {
97
+ const history = await jobFieldHistory(db, await organisationOf(req), req.query);
98
+ res.json({ ok: true, history });
99
+ }));
100
+
101
+ router.put('/:id', ...manager, handle(async (req, res) => {
102
+ const field = await updateField(db, await organisationOf(req), req.params.id, req.body, { personId: req.person.id });
103
+ res.json({ ok: true, field });
104
+ }));
105
+
106
+ router.post('/:id/archive', ...manager, handle(async (req, res) => {
107
+ const archived = req.body?.archived ?? true;
108
+ if (typeof archived !== 'boolean') throw new FlowError('invalid', 'archived must be true or false');
109
+ const field = await archiveField(db, await organisationOf(req), req.params.id, { archived, personId: req.person.id });
110
+ res.json({ ok: true, field });
111
+ }));
112
+
113
+ return router;
114
+ }
@@ -277,7 +277,8 @@ export function createInvoicingRouter({
277
277
  const rows = await db.select({
278
278
  id: organisations.id, name: organisations.name, invoicePrefix: organisations.invoicePrefix, nextInvoiceNumber: organisations.nextInvoiceNumber,
279
279
  }).from(organisations).orderBy(asc(organisations.name));
280
- res.json({ ok: true, organisations: rows, canIssue: ['owner', 'admin'].includes(req.person.role) });
280
+ // sendsTo: the accounting system a sent invoice goes to, or null.
281
+ res.json({ ok: true, organisations: rows, canIssue: ['owner', 'admin'].includes(req.person.role), sendsTo: accounting()?.name ?? null });
281
282
  }));
282
283
 
283
284
  router.get('/invoices', ...signedIn, handle(log, async (req, res) => {
@@ -0,0 +1,80 @@
1
+ import express from 'express';
2
+ import { FlowError } from '../core/errors.js';
3
+
4
+ // Jobs board moves that belong to the work system (docs/JOB_BOARD_MOVES.md).
5
+ //
6
+ // POST /api/work/moves { sourceSystem, sourceJobRef, toStatus }
7
+ // a person moved a job to another stage: the work system answers what
8
+ // happens. Seazona's API cannot change a stage, so it answers a
9
+ // hand-off: what to do in Seazona, and how long Flow waits.
10
+ // POST /api/work/moves/cancel { sourceSystem, sourceJobRef } take back a waiting move
11
+ // GET /api/work/my-settings[?integration=] this person's own integration choices
12
+ // PUT /api/work/my-settings { integration, key, value } change one
13
+ //
14
+ // A move of a Flow custom field never comes here: that is
15
+ // PUT /api/custom-fields/values, and it stays in Flow.
16
+ //
17
+ // `adapters(capability)` is the enabled, ready adapters that offer it.
18
+ const ERROR_STATUS = { not_found: 404, conflict: 409, not_supported: 409 };
19
+ const SYSTEM = /^[a-z][a-z0-9-]{1,31}$/;
20
+
21
+ export function createWorkMovesRouter({ adapters = () => [], personAuth, log = console }) {
22
+ const router = express.Router();
23
+ router.use(express.json({ limit: '8kb' }));
24
+ const signedIn = [personAuth.requireSession];
25
+
26
+ const handle = (fn) => async (req, res) => {
27
+ try {
28
+ res.set('Cache-Control', 'no-store');
29
+ await fn(req, res);
30
+ } catch (error) {
31
+ if (!(error instanceof FlowError)) {
32
+ log.error?.(`[work-moves] ${error.message}`);
33
+ return res.status(500).json({ ok: false, code: 'internal', error: 'Something went wrong.' });
34
+ }
35
+ return res.status(ERROR_STATUS[error.code] ?? 400).json({ ok: false, code: error.code, error: error.message });
36
+ }
37
+ };
38
+
39
+ // The enabled adapter `id` that offers `capability`.
40
+ const adapterFor = (id, capability) => {
41
+ if (typeof id !== 'string' || !SYSTEM.test(id)) throw new FlowError('invalid', 'name an integration');
42
+ const adapter = adapters(capability).find((a) => a.id === id) ?? null;
43
+ if (!adapter) throw new FlowError('not_supported', 'that integration is not on, or cannot do that');
44
+ return adapter;
45
+ };
46
+ const jobRef = (raw) => {
47
+ const ref = typeof raw === 'string' ? raw.trim() : '';
48
+ if (!ref || ref.length > 200) throw new FlowError('invalid', 'sourceJobRef is required');
49
+ return ref;
50
+ };
51
+
52
+ router.post('/moves', ...signedIn, handle(async (req, res) => {
53
+ const adapter = adapterFor(req.body?.sourceSystem, 'moveWork');
54
+ const move = await adapter.capabilities.moveWork({ sourceJobRef: jobRef(req.body?.sourceJobRef), toStatus: req.body?.toStatus, personId: req.person.id });
55
+ res.json({ ok: true, move });
56
+ }));
57
+
58
+ router.post('/moves/cancel', ...signedIn, handle(async (req, res) => {
59
+ const adapter = adapterFor(req.body?.sourceSystem, 'cancelMove');
60
+ res.json({ ok: true, ...(await adapter.capabilities.cancelMove({ sourceJobRef: jobRef(req.body?.sourceJobRef) })) });
61
+ }));
62
+
63
+ router.get('/my-settings', ...signedIn, handle(async (req, res) => {
64
+ const wanted = typeof req.query.integration === 'string' ? req.query.integration : null;
65
+ const out = [];
66
+ for (const adapter of adapters('personSettings')) {
67
+ if (wanted && adapter.id !== wanted) continue;
68
+ out.push({ integration: adapter.id, name: adapter.name, settings: await adapter.capabilities.personSettings({ personId: req.person.id }) });
69
+ }
70
+ res.json({ ok: true, integrations: out });
71
+ }));
72
+
73
+ router.put('/my-settings', ...signedIn, handle(async (req, res) => {
74
+ const adapter = adapterFor(req.body?.integration, 'setPersonSetting');
75
+ const settings = await adapter.capabilities.setPersonSetting({ personId: req.person.id, key: req.body?.key, value: req.body?.value });
76
+ res.json({ ok: true, integration: adapter.id, settings });
77
+ }));
78
+
79
+ return router;
80
+ }
package/server/app.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import express from 'express';
2
2
  import { createAlertsRouter } from './api/alerts.js';
3
+ import { createCustomFieldsRouter } from './api/custom-fields.js';
4
+ import { createWorkMovesRouter } from './api/work-moves.js';
3
5
  import { createConnectorApi } from './api/connector.js';
4
6
  import { createInvoicingRouter } from './api/invoicing.js';
5
7
  import { createSignInRouter } from './auth/routes.js';
@@ -35,6 +37,8 @@ import { createUiAssetsRouter } from './ui/routes.js';
35
37
  // the migration journals this build ships (server/db/doctor.js): GET /health
36
38
  // reports how many of each the database has applied, so the workspace's
37
39
  // doctor can tell what Replit's publish step will offer (docs/REPLIT.md).
40
+ // `tracing` (server/core/tracing.js) names each request's performance trace
41
+ // by its route, while a tracing service is on.
38
42
  export function createApp({
39
43
  build = deployedBuildStatus(),
40
44
  database = null,
@@ -49,10 +53,12 @@ export function createApp({
49
53
  clerkProxyRateLimitPerMinute = 300,
50
54
  defaultFamily = DEFAULT_FAMILY,
51
55
  log = console,
56
+ tracing = null,
52
57
  } = {}) {
53
58
  const app = express();
54
59
  app.disable('x-powered-by');
55
60
  app.set('trust proxy', trustProxy);
61
+ if (tracing) app.use(tracing.requests());
56
62
 
57
63
  app.get('/health', async (_req, res) => {
58
64
  let databaseOk = null;
@@ -136,6 +142,12 @@ export function createApp({
136
142
  // items, Seazona products).
137
143
  const priceSystems = () => integrations?.with('listPriceItems') ?? [];
138
144
  app.use('/api/invoicing', createInvoicingRouter({ db: database.db, personAuth: auth.personAuth, log, liveCheckFor, accounting, work, priceSystems }));
145
+ // Custom fields for jobs (server/core/custom-fields.js): kept in Flow
146
+ // only; the work systems' statuses show beside them, read only.
147
+ app.use('/api/custom-fields', createCustomFieldsRouter({ db: database.db, personAuth: auth.personAuth, work, log }));
148
+ // Stage moves the work system must make, and each person's own
149
+ // integration choices (server/api/work-moves.js).
150
+ app.use('/api/work', createWorkMovesRouter({ adapters: (capability) => integrations?.with(capability) ?? [], personAuth: auth.personAuth, log }));
139
151
  // Quiet alerts (server/core/alerts.js): what needs a person on each page.
140
152
  // Integrations add their own through the `alerts` capability.
141
153
  app.use('/api/alerts', createAlertsRouter({
@@ -0,0 +1,300 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { and, asc, eq, inArray, isNull, sql } from 'drizzle-orm';
3
+ import { CUSTOM_FIELD_TYPES, jobFieldEvents, jobFields, jobFieldValues, people } from '../db/schema.js';
4
+ import { FlowError, notFound } from './errors.js';
5
+
6
+ // Custom fields (docs/CUSTOM_FIELDS.md): fields a lab makes for its jobs in
7
+ // Flow, like ClickUp's custom fields. An owner or admin makes them in
8
+ // Settings, Custom fields; everyone can set a job's value. A job is the work
9
+ // system's job (sourceSystem, sourceJobRef). A value lives only in Flow: a
10
+ // change never writes to the work system or the accounting system.
11
+
12
+ export { CUSTOM_FIELD_TYPES };
13
+ // The types that make lanes on the Jobs board: one lane per option, per
14
+ // person, or yes and no.
15
+ export const GROUPABLE_TYPES = ['dropdown', 'person', 'checkbox'];
16
+ export const OPTION_COLORS = ['gray', 'blue', 'teal', 'green', 'amber', 'red', 'purple', 'pink'];
17
+
18
+ const MAX_FIELDS = 50;
19
+ const MAX_OPTIONS = 50;
20
+ const MAX_NAME = 60;
21
+ const MAX_OPTION_NAME = 40;
22
+ const MAX_TEXT = 500;
23
+ const MAX_NUMBER = 1e12;
24
+ const DATE = /^\d{4}-\d{2}-\d{2}$/;
25
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
26
+ const SYSTEM = /^[a-z][a-z0-9-]{1,31}$/;
27
+
28
+ const invalid = (message) => new FlowError('invalid', message);
29
+
30
+ function readName(raw, what, max) {
31
+ if (typeof raw !== 'string' || !raw.trim()) throw invalid(`${what} is required`);
32
+ const name = raw.trim().replace(/\s+/g, ' ');
33
+ if (name.length > max) throw invalid(`${what} is too long (${max} characters at most)`);
34
+ return name;
35
+ }
36
+
37
+ // A dropdown's options as a person sends them: [{ id?, name, color? }], in
38
+ // lane order. An option with no id is new. An earlier option that is not in
39
+ // the list is kept as archived, so its old values still read.
40
+ export function readOptions(raw, previous = []) {
41
+ if (!Array.isArray(raw)) throw invalid('options must be a list');
42
+ if (!raw.length) throw invalid('a dropdown needs at least one option');
43
+ if (raw.length > MAX_OPTIONS) throw invalid(`a dropdown has ${MAX_OPTIONS} options at most`);
44
+ const known = new Map(previous.map((o) => [o.id, o]));
45
+ const names = new Set();
46
+ const active = raw.map((option, i) => {
47
+ if (!option || typeof option !== 'object') throw invalid(`option ${i + 1} must be an object`);
48
+ const name = readName(option.name, `option ${i + 1} name`, MAX_OPTION_NAME);
49
+ const key = name.toLowerCase();
50
+ if (names.has(key)) throw invalid(`two options are named ${name}`);
51
+ names.add(key);
52
+ const color = option.color === undefined || option.color === null ? 'gray' : option.color;
53
+ if (!OPTION_COLORS.includes(color)) throw invalid(`option ${name}: color must be one of ${OPTION_COLORS.join(', ')}`);
54
+ if (option.id !== undefined && option.id !== null) {
55
+ if (!known.has(option.id)) throw invalid(`option ${name}: unknown id`);
56
+ return { id: option.id, name, color };
57
+ }
58
+ return { id: randomUUID(), name, color };
59
+ });
60
+ const kept = new Set(active.map((o) => o.id));
61
+ const archived = previous.filter((o) => !kept.has(o.id)).map((o) => ({ ...o, archived: true }));
62
+ return [...active, ...archived];
63
+ }
64
+
65
+ // A new field, or the part of one a person changes. The type is fixed once
66
+ // the field exists.
67
+ export function readField(raw, { partial = false, previous = null } = {}) {
68
+ const input = raw && typeof raw === 'object' ? raw : {};
69
+ const out = {};
70
+ if (!partial || input.name !== undefined) out.name = readName(input.name, 'name', MAX_NAME);
71
+ if (!partial) {
72
+ if (!CUSTOM_FIELD_TYPES.includes(input.type)) throw invalid(`type must be one of ${CUSTOM_FIELD_TYPES.join(', ')}`);
73
+ out.type = input.type;
74
+ } else if (input.type !== undefined && input.type !== previous?.type) {
75
+ throw invalid('the type of a field cannot change; make a new field instead');
76
+ }
77
+ const type = out.type ?? previous?.type;
78
+ if (type === 'dropdown' && (!partial || input.options !== undefined)) out.options = readOptions(input.options, previous?.options ?? []);
79
+ else if (type !== 'dropdown' && input.options !== undefined && input.options !== null && !(Array.isArray(input.options) && !input.options.length)) {
80
+ throw invalid('only a dropdown has options');
81
+ }
82
+ return out;
83
+ }
84
+
85
+ export const publicField = (row) => ({
86
+ id: row.id,
87
+ name: row.name,
88
+ type: row.type,
89
+ options: row.options ?? [],
90
+ position: row.position,
91
+ groupable: GROUPABLE_TYPES.includes(row.type),
92
+ archived: Boolean(row.archivedAt),
93
+ createdBy: row.createdBy ?? null,
94
+ createdAt: row.createdAt?.toISOString?.() ?? row.createdAt,
95
+ updatedAt: row.updatedAt?.toISOString?.() ?? row.updatedAt,
96
+ });
97
+
98
+ export async function listFields(db, organisationId, { archived = false } = {}) {
99
+ const where = [eq(jobFields.organisationId, organisationId)];
100
+ if (!archived) where.push(isNull(jobFields.archivedAt));
101
+ const rows = await db.select().from(jobFields).where(and(...where)).orderBy(asc(jobFields.position), asc(jobFields.createdAt));
102
+ return rows.map(publicField);
103
+ }
104
+
105
+ async function readRow(db, organisationId, id) {
106
+ if (!UUID.test(id ?? '')) throw notFound('field');
107
+ const [row] = await db.select().from(jobFields).where(and(eq(jobFields.organisationId, organisationId), eq(jobFields.id, id)));
108
+ if (!row) throw notFound('field');
109
+ return row;
110
+ }
111
+
112
+ async function checkNameFree(db, organisationId, name, exceptId = null) {
113
+ const rows = await db.select({ id: jobFields.id }).from(jobFields).where(and(
114
+ eq(jobFields.organisationId, organisationId), isNull(jobFields.archivedAt),
115
+ sql`lower(${jobFields.name}) = ${name.toLowerCase()}`,
116
+ ));
117
+ if (rows.some((r) => r.id !== exceptId)) throw new FlowError('conflict', `a field named ${name} already exists`);
118
+ }
119
+
120
+ export async function createField(db, organisationId, raw, { personId = null } = {}) {
121
+ const field = readField(raw);
122
+ return db.transaction(async (tx) => {
123
+ const [{ count, last }] = await tx.select({
124
+ count: sql`count(*) filter (where ${jobFields.archivedAt} is null)`.mapWith(Number),
125
+ last: sql`coalesce(max(${jobFields.position}), -1)`.mapWith(Number),
126
+ }).from(jobFields).where(eq(jobFields.organisationId, organisationId));
127
+ if (count >= MAX_FIELDS) throw invalid(`an organisation has ${MAX_FIELDS} fields at most`);
128
+ await checkNameFree(tx, organisationId, field.name);
129
+ const [row] = await tx.insert(jobFields).values({
130
+ organisationId, ...field, options: field.options ?? [], position: last + 1, createdBy: personId, updatedBy: personId,
131
+ }).returning();
132
+ return publicField(row);
133
+ });
134
+ }
135
+
136
+ export async function updateField(db, organisationId, id, raw, { personId = null } = {}) {
137
+ const previous = await readRow(db, organisationId, id);
138
+ if (previous.archivedAt) throw new FlowError('conflict', 'the field is archived; bring it back first');
139
+ const change = readField(raw, { partial: true, previous });
140
+ if (change.name) await checkNameFree(db, organisationId, change.name, id);
141
+ const [row] = await db.update(jobFields).set({ ...change, updatedBy: personId, updatedAt: new Date() })
142
+ .where(and(eq(jobFields.organisationId, organisationId), eq(jobFields.id, id))).returning();
143
+ return publicField(row);
144
+ }
145
+
146
+ // Archive a field, or bring it back. Its values stay, so bringing it back
147
+ // restores them.
148
+ export async function archiveField(db, organisationId, id, { archived = true, personId = null } = {}) {
149
+ const previous = await readRow(db, organisationId, id);
150
+ if (!archived) await checkNameFree(db, organisationId, previous.name, id);
151
+ const [row] = await db.update(jobFields).set({ archivedAt: archived ? new Date() : null, updatedBy: personId, updatedAt: new Date() })
152
+ .where(and(eq(jobFields.organisationId, organisationId), eq(jobFields.id, id))).returning();
153
+ return publicField(row);
154
+ }
155
+
156
+ // Put the fields in this order. `ids` lists every field that is not archived.
157
+ export async function orderFields(db, organisationId, ids, { personId = null } = {}) {
158
+ if (!Array.isArray(ids) || ids.some((i) => typeof i !== 'string')) throw invalid('ids must be a list of field ids');
159
+ const current = await listFields(db, organisationId);
160
+ const want = new Set(ids);
161
+ if (want.size !== ids.length || current.length !== ids.length || current.some((f) => !want.has(f.id))) {
162
+ throw invalid('ids must list every field that is not archived, once each');
163
+ }
164
+ await db.transaction(async (tx) => {
165
+ for (const [position, id] of ids.entries()) {
166
+ await tx.update(jobFields).set({ position, updatedBy: personId, updatedAt: new Date() })
167
+ .where(and(eq(jobFields.organisationId, organisationId), eq(jobFields.id, id)));
168
+ }
169
+ });
170
+ return listFields(db, organisationId);
171
+ }
172
+
173
+ function readJob(raw) {
174
+ const sourceSystem = typeof raw?.sourceSystem === 'string' ? raw.sourceSystem.trim() : '';
175
+ const sourceJobRef = typeof raw?.sourceJobRef === 'string' ? raw.sourceJobRef.trim() : '';
176
+ if (!SYSTEM.test(sourceSystem)) throw invalid('sourceSystem must be an integration id');
177
+ if (!sourceJobRef || sourceJobRef.length > 200) throw invalid('sourceJobRef is required (200 characters at most)');
178
+ return { sourceSystem, sourceJobRef };
179
+ }
180
+
181
+ function isRealDate(value) {
182
+ if (typeof value !== 'string' || !DATE.test(value)) return false;
183
+ const d = new Date(`${value}T00:00:00Z`);
184
+ return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === value;
185
+ }
186
+
187
+ // A value checked against its field. null clears the value.
188
+ export async function readValue(db, field, raw) {
189
+ if (raw === null || raw === undefined || raw === '') return null;
190
+ switch (field.type) {
191
+ case 'dropdown': {
192
+ const option = (field.options ?? []).find((o) => o.id === raw && !o.archived);
193
+ if (!option) throw invalid(`${field.name}: choose one of its options`);
194
+ return option.id;
195
+ }
196
+ case 'person': {
197
+ if (typeof raw !== 'string' || !UUID.test(raw)) throw invalid(`${field.name}: choose a person`);
198
+ const [row] = await db.select({ id: people.id }).from(people).where(and(eq(people.id, raw), eq(people.status, 'active')));
199
+ if (!row) throw invalid(`${field.name}: that person cannot be chosen`);
200
+ return row.id;
201
+ }
202
+ case 'checkbox':
203
+ if (typeof raw !== 'boolean') throw invalid(`${field.name} must be true or false`);
204
+ return raw;
205
+ case 'date':
206
+ if (!isRealDate(raw)) throw invalid(`${field.name} must be a date, YYYY-MM-DD`);
207
+ return raw;
208
+ case 'number':
209
+ if (typeof raw !== 'number' || !Number.isFinite(raw) || Math.abs(raw) >= MAX_NUMBER) throw invalid(`${field.name} must be a number`);
210
+ return raw;
211
+ default:
212
+ if (typeof raw !== 'string') throw invalid(`${field.name} must be text`);
213
+ if (raw.length > MAX_TEXT) throw invalid(`${field.name} is too long (${MAX_TEXT} characters at most)`);
214
+ return raw.trim() || null;
215
+ }
216
+ }
217
+
218
+ const publicValue = (row) => ({
219
+ fieldId: row.fieldId,
220
+ sourceSystem: row.sourceSystem,
221
+ sourceJobRef: row.sourceJobRef,
222
+ value: row.value,
223
+ updatedBy: row.updatedBy ?? null,
224
+ updatedAt: row.updatedAt?.toISOString?.() ?? row.updatedAt,
225
+ });
226
+
227
+ // Set one job's value for one field, as a drag between lanes does.
228
+ // `expectedUpdatedAt` is the updatedAt of the value the person last saw
229
+ // (null when they saw none). When someone changed it since, nothing changes
230
+ // and the error carries the current value, so the board can show it.
231
+ export async function setJobFieldValue(db, organisationId, raw, { personId = null } = {}) {
232
+ const job = readJob(raw);
233
+ const field = await readRow(db, organisationId, raw?.fieldId);
234
+ if (field.archivedAt) throw new FlowError('conflict', 'the field is archived');
235
+ const value = await readValue(db, field, raw?.value);
236
+ const checkVersion = raw && Object.hasOwn(raw, 'expectedUpdatedAt');
237
+ const expected = checkVersion && raw.expectedUpdatedAt ? new Date(raw.expectedUpdatedAt) : null;
238
+ if (expected && Number.isNaN(expected.getTime())) throw invalid('expectedUpdatedAt must be a time');
239
+ const key = and(
240
+ eq(jobFieldValues.organisationId, organisationId), eq(jobFieldValues.fieldId, field.id),
241
+ eq(jobFieldValues.sourceSystem, job.sourceSystem), eq(jobFieldValues.sourceJobRef, job.sourceJobRef),
242
+ );
243
+ return db.transaction(async (tx) => {
244
+ // One change at a time per job and field, also while no row exists yet.
245
+ await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${`${organisationId}:${field.id}:${job.sourceSystem}:${job.sourceJobRef}`}))`);
246
+ const [current] = await tx.select().from(jobFieldValues).where(key);
247
+ if (checkVersion) {
248
+ const seen = current?.updatedAt?.getTime() ?? null;
249
+ if (seen !== (expected?.getTime() ?? null)) {
250
+ throw new FlowError('conflict', 'someone changed this value since you saw it', { current: current ? publicValue(current) : null });
251
+ }
252
+ }
253
+ const from = current?.value ?? null;
254
+ if (JSON.stringify(from) === JSON.stringify(value)) return current ? publicValue(current) : { fieldId: field.id, ...job, value: null, updatedBy: null, updatedAt: null };
255
+ let saved = null;
256
+ if (value === null) {
257
+ await tx.delete(jobFieldValues).where(key);
258
+ } else {
259
+ [saved] = await tx.insert(jobFieldValues).values({ organisationId, fieldId: field.id, ...job, value, updatedBy: personId, updatedAt: new Date() })
260
+ .onConflictDoUpdate({
261
+ target: [jobFieldValues.organisationId, jobFieldValues.fieldId, jobFieldValues.sourceSystem, jobFieldValues.sourceJobRef],
262
+ set: { value, updatedBy: personId, updatedAt: new Date() },
263
+ }).returning();
264
+ }
265
+ await tx.insert(jobFieldEvents).values({ organisationId, fieldId: field.id, ...job, fromValue: from, toValue: value, actor: personId });
266
+ return saved ? publicValue(saved) : { fieldId: field.id, ...job, value: null, updatedBy: personId, updatedAt: null };
267
+ });
268
+ }
269
+
270
+ // Every value of the fields that are not archived, optionally for some jobs
271
+ // only (`jobs`: [{ sourceSystem, sourceJobRef }]).
272
+ export async function listJobFieldValues(db, organisationId, { jobs = null } = {}) {
273
+ const where = [eq(jobFieldValues.organisationId, organisationId), isNull(jobFields.archivedAt)];
274
+ if (jobs) {
275
+ if (!jobs.length) return [];
276
+ const refs = [...new Set(jobs.map((j) => j.sourceJobRef))];
277
+ where.push(inArray(jobFieldValues.sourceJobRef, refs));
278
+ }
279
+ const rows = await db.select({
280
+ fieldId: jobFieldValues.fieldId, sourceSystem: jobFieldValues.sourceSystem, sourceJobRef: jobFieldValues.sourceJobRef,
281
+ value: jobFieldValues.value, updatedBy: jobFieldValues.updatedBy, updatedAt: jobFieldValues.updatedAt,
282
+ }).from(jobFieldValues)
283
+ .innerJoin(jobFields, and(eq(jobFields.organisationId, jobFieldValues.organisationId), eq(jobFields.id, jobFieldValues.fieldId)))
284
+ .where(and(...where));
285
+ const wanted = jobs ? new Set(jobs.map((j) => `${j.sourceSystem}\u0000${j.sourceJobRef}`)) : null;
286
+ return rows.filter((r) => !wanted || wanted.has(`${r.sourceSystem}\u0000${r.sourceJobRef}`)).map(publicValue);
287
+ }
288
+
289
+ // What happened to one job's custom field values, newest first.
290
+ export async function jobFieldHistory(db, organisationId, raw, { limit = 50 } = {}) {
291
+ const job = readJob(raw);
292
+ const rows = await db.select().from(jobFieldEvents).where(and(
293
+ eq(jobFieldEvents.organisationId, organisationId),
294
+ eq(jobFieldEvents.sourceSystem, job.sourceSystem), eq(jobFieldEvents.sourceJobRef, job.sourceJobRef),
295
+ )).orderBy(sql`${jobFieldEvents.createdAt} desc`).limit(limit);
296
+ return rows.map((r) => ({
297
+ fieldId: r.fieldId, from: r.fromValue, to: r.toValue, actor: r.actor ?? null,
298
+ at: r.createdAt?.toISOString?.() ?? r.createdAt,
299
+ }));
300
+ }
@@ -136,6 +136,8 @@ export async function boardJobs(db, organisationId, { work = [], accounting = nu
136
136
  totalCents: o?.totalCents ?? item.totalCents ?? null,
137
137
  lines: o ? ledgerLines(o.lines)
138
138
  : (item.lines ?? []).map((l) => ({ code: l.code ?? null, description: l.description, amountCents: l.amountCents ?? null, mapped: Boolean(l.code && codes.has(l.code)) })),
139
+ // A stage move the work system has not made yet (moveWork).
140
+ pendingMove: item.pendingMove ?? null,
139
141
  obligation: o,
140
142
  });
141
143
  }