@arcforgelabs/flow 2026.10.34 → 2026.10.36

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 CHANGED
@@ -40,7 +40,7 @@ run a lab.
40
40
  | `docs/JOB_INVOICE_LINKS.md` | The pipeline: how each job links to its customer and to the accounting invoice that bills it |
41
41
  | `docs/INVOICING_RULES.md` | The invoicing rules that draft a job's invoice, and the two ways an invoice goes to Xero |
42
42
  | `docs/INVOICE_RULES.md` | Invoice rules a lab builds (When, If, Then) that make a ready job wait or never draft |
43
- | `docs/INVOICE_SENDING.md` | Drafts, Problem and Ready invoices, Mark ready, and Send all ready to the accounting system |
43
+ | `docs/INVOICE_SENDING.md` | Drafts, Problem and Ready invoices, Finalise, a changed job, and Send all ready to the accounting system |
44
44
  | `docs/RECONCILE.md` | The daily reconcile: counts and totals per day across the work system's copy, the ledger and the accounting copy |
45
45
  | `docs/ALERTS.md` | Quiet alerts: the slim line on a page when something there needs a person, and how a person dismisses it |
46
46
  | `docs/AUTOMATIONS.md` | Automations: Flow routines that mark jobs ready to invoice and draft them, each on or off |
@@ -48,6 +48,7 @@ run a lab.
48
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
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 |
50
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/AI_BOTS.md` | The AI bots: ClawSweeper reviews and the Docs Agent, their ClawRouter budget and cost, and setup |
51
52
  | `docs/LOW_RISK_MERGE.md` | The low-risk merge lane: which pull requests merge themselves when green, with ClawSweeper's risk labels |
52
53
  | `docs/RELEASING.md` | Versions and channels, the release workflows, how to cut and roll back a release, and the one-time setup |
53
54
  | `docs/SELF_HOSTING.md` | Running a Flow release with Docker, Node or Replit, the update notice, and Clerk sign-in |
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.34",
3
+ "version": "2026.10.36",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@arcforgelabs/flow",
9
- "version": "2026.10.34",
9
+ "version": "2026.10.36",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
- "@clerk/backend": "3.23.2",
12
+ "@clerk/backend": "3.25.0",
13
13
  "@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
14
14
  "@fontsource-variable/dm-sans": "5.3.0",
15
15
  "@fontsource-variable/geist": "5.3.0",
@@ -21,10 +21,11 @@
21
21
  "@fontsource-variable/playfair-display": "5.3.0",
22
22
  "@fontsource-variable/plus-jakarta-sans": "5.3.0",
23
23
  "@fontsource-variable/space-grotesk": "5.3.0",
24
- "@sentry/node": "11.4.0",
25
- "drizzle-orm": "0.45.3",
24
+ "@sentry/node": "11.8.0",
25
+ "drizzle-orm": "0.45.4",
26
26
  "express": "5.3.0",
27
- "express-rate-limit": "8.7.0",
27
+ "express-rate-limit": "8.7.1",
28
+ "lucide-static": "1.59.0",
28
29
  "pdfkit": "0.20.2",
29
30
  "pg": "8.23.1"
30
31
  },
@@ -39,9 +40,9 @@
39
40
  }
40
41
  },
41
42
  "node_modules/@clerk/backend": {
42
- "version": "3.23.2",
43
- "resolved": "https://registry.npmjs.org/@clerk/backend/-/backend-3.23.2.tgz",
44
- "integrity": "sha512-QW2qJPY7xQyn5SrNWJ+xO7o6va60X9/v4nX8y5zq8OJFS10X7GlEz8s5PDKTgo6W6SWb8L3APhrZH+LTp7yuNw==",
43
+ "version": "3.25.0",
44
+ "resolved": "https://registry.npmjs.org/@clerk/backend/-/backend-3.25.0.tgz",
45
+ "integrity": "sha512-hmJS0a61FO08PDaV2MtZ/hj6pE+cQBRmpxR8oUFGQTXewOQLLOAbcgob0rmFAnMNuHgXfsYf3NVxcQCUOWE+QA==",
45
46
  "license": "MIT",
46
47
  "dependencies": {
47
48
  "@clerk/shared": "^4.40.0",
@@ -809,6 +810,9 @@
809
810
  "cpu": [
810
811
  "arm64"
811
812
  ],
813
+ "libc": [
814
+ "glibc"
815
+ ],
812
816
  "license": "MIT",
813
817
  "optional": true,
814
818
  "os": [
@@ -825,6 +829,9 @@
825
829
  "cpu": [
826
830
  "arm64"
827
831
  ],
832
+ "libc": [
833
+ "musl"
834
+ ],
828
835
  "license": "MIT",
829
836
  "optional": true,
830
837
  "os": [
@@ -841,6 +848,9 @@
841
848
  "cpu": [
842
849
  "ppc64"
843
850
  ],
851
+ "libc": [
852
+ "glibc"
853
+ ],
844
854
  "license": "MIT",
845
855
  "optional": true,
846
856
  "os": [
@@ -857,6 +867,9 @@
857
867
  "cpu": [
858
868
  "riscv64"
859
869
  ],
870
+ "libc": [
871
+ "glibc"
872
+ ],
860
873
  "license": "MIT",
861
874
  "optional": true,
862
875
  "os": [
@@ -873,6 +886,9 @@
873
886
  "cpu": [
874
887
  "riscv64"
875
888
  ],
889
+ "libc": [
890
+ "musl"
891
+ ],
876
892
  "license": "MIT",
877
893
  "optional": true,
878
894
  "os": [
@@ -889,6 +905,9 @@
889
905
  "cpu": [
890
906
  "s390x"
891
907
  ],
908
+ "libc": [
909
+ "glibc"
910
+ ],
892
911
  "license": "MIT",
893
912
  "optional": true,
894
913
  "os": [
@@ -905,6 +924,9 @@
905
924
  "cpu": [
906
925
  "x64"
907
926
  ],
927
+ "libc": [
928
+ "glibc"
929
+ ],
908
930
  "license": "MIT",
909
931
  "optional": true,
910
932
  "os": [
@@ -921,6 +943,9 @@
921
943
  "cpu": [
922
944
  "x64"
923
945
  ],
946
+ "libc": [
947
+ "musl"
948
+ ],
924
949
  "license": "MIT",
925
950
  "optional": true,
926
951
  "os": [
@@ -1004,12 +1029,12 @@
1004
1029
  }
1005
1030
  },
1006
1031
  "node_modules/@sentry/bundler-plugins": {
1007
- "version": "11.4.0",
1008
- "resolved": "https://registry.npmjs.org/@sentry/bundler-plugins/-/bundler-plugins-11.4.0.tgz",
1009
- "integrity": "sha512-THgOMIaA0AMVF4TiCIbugK2jlLYCBIhRF0gclrj7Fte3RMZytkXeH8XeoaR6C6MJTyLCNkEOEccwX9Jj2xOc8g==",
1032
+ "version": "11.8.0",
1033
+ "resolved": "https://registry.npmjs.org/@sentry/bundler-plugins/-/bundler-plugins-11.8.0.tgz",
1034
+ "integrity": "sha512-rQrbL2lYPyiiO6ngdpbbfPKyEzVVQsz1FG+aE8jUiUYs72gvmDmZmRyl9VDCGLrHZfIEwaQMT1t6WQH5nopu5A==",
1010
1035
  "license": "MIT",
1011
1036
  "dependencies": {
1012
- "@sentry/core": "11.4.0",
1037
+ "@sentry/core": "11.8.0",
1013
1038
  "dotenv": "^17.4.2",
1014
1039
  "glob": "^13.0.6",
1015
1040
  "magic-string": "~0.30.8 || ^1.0.0",
@@ -1038,52 +1063,52 @@
1038
1063
  }
1039
1064
  },
1040
1065
  "node_modules/@sentry/conventions": {
1041
- "version": "0.25.0",
1042
- "resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.25.0.tgz",
1043
- "integrity": "sha512-7SouCAX3qx+AzLsxb6gbl3dKVWNhp2wFjdYQ/T90sGwqPPQUoZVA9YwsWwvx07hqcCiSqFW9mUAzyUftASnuhQ==",
1066
+ "version": "0.27.0",
1067
+ "resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.27.0.tgz",
1068
+ "integrity": "sha512-Mrqd/o7n/z7qWSYfQQA7x4fFTA0IqOG6JNsbBpnC97khPwZk7n6DPtOO7YgaYplSl8R8QLRbBz9Xv2lVhToSAg==",
1044
1069
  "license": "MIT",
1045
1070
  "engines": {
1046
1071
  "node": ">=14"
1047
1072
  }
1048
1073
  },
1049
1074
  "node_modules/@sentry/core": {
1050
- "version": "11.4.0",
1051
- "resolved": "https://registry.npmjs.org/@sentry/core/-/core-11.4.0.tgz",
1052
- "integrity": "sha512-kAhK8XVJuzu1IKx9uSu04OISxTU9BcIyDVOWbY/ATUCYr5Z6L7GRBtOGOlYCbxEuPtdZCw5TrbphnBsyO9fuTA==",
1075
+ "version": "11.8.0",
1076
+ "resolved": "https://registry.npmjs.org/@sentry/core/-/core-11.8.0.tgz",
1077
+ "integrity": "sha512-4MVTthk6C/1oQFjbVIfygHyNkx2YGbwNLml0kJji0vjJXy6ZNls9zdIp0FTBTi4e3189IcSc5OXD4qcJdmEeKA==",
1053
1078
  "license": "MIT",
1054
1079
  "dependencies": {
1055
- "@sentry/conventions": "^0.25.0"
1080
+ "@sentry/conventions": "^0.27.0"
1056
1081
  },
1057
1082
  "engines": {
1058
1083
  "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0"
1059
1084
  }
1060
1085
  },
1061
1086
  "node_modules/@sentry/node": {
1062
- "version": "11.4.0",
1063
- "resolved": "https://registry.npmjs.org/@sentry/node/-/node-11.4.0.tgz",
1064
- "integrity": "sha512-fW587LsZnsuGt1V++59xFCtZ/7jGO3GQq1F+l/Jx5DS18QApXQ99vlfS9H5bxuWixfi5/22ea9M8XjFpkkuzQg==",
1087
+ "version": "11.8.0",
1088
+ "resolved": "https://registry.npmjs.org/@sentry/node/-/node-11.8.0.tgz",
1089
+ "integrity": "sha512-8vFPfCRYoUYmsno+OYdvtf5akIUUvd7FvcuIGcfrge8S/bSLFRKq58YtFmX25JFcrMNZ2oQD8r0jkgZfm/2iuA==",
1065
1090
  "license": "MIT",
1066
1091
  "dependencies": {
1067
1092
  "@opentelemetry/api": "^1.9.1",
1068
- "@sentry/bundler-plugins": "11.4.0",
1069
- "@sentry/conventions": "^0.25.0",
1070
- "@sentry/core": "11.4.0",
1071
- "@sentry/opentelemetry": "11.4.0",
1072
- "@sentry/server-runtime-injection": "11.4.0",
1073
- "@sentry/server-utils": "11.4.0"
1093
+ "@sentry/bundler-plugins": "11.8.0",
1094
+ "@sentry/conventions": "^0.27.0",
1095
+ "@sentry/core": "11.8.0",
1096
+ "@sentry/opentelemetry": "11.8.0",
1097
+ "@sentry/server-runtime-injection": "11.8.0",
1098
+ "@sentry/server-utils": "11.8.0"
1074
1099
  },
1075
1100
  "engines": {
1076
1101
  "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0"
1077
1102
  }
1078
1103
  },
1079
1104
  "node_modules/@sentry/opentelemetry": {
1080
- "version": "11.4.0",
1081
- "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-11.4.0.tgz",
1082
- "integrity": "sha512-GGDmV+VIGU1p3KGtgR32wrRJejSfEuafbEcSUK7Ww5oxDMYJyHXRSbFjVvGMoAG1ezTl0U6WtMUoWmalsfYHZg==",
1105
+ "version": "11.8.0",
1106
+ "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-11.8.0.tgz",
1107
+ "integrity": "sha512-wKuG2SbHHiGDT/fgT4X5n2usO4AjVkYfAj41002mZ18JZU3mNLeCq+qw/nnDIYbcsfIMOUm/eMh3ShKCE+DpXQ==",
1083
1108
  "license": "MIT",
1084
1109
  "dependencies": {
1085
- "@sentry/conventions": "^0.25.0",
1086
- "@sentry/core": "11.4.0"
1110
+ "@sentry/conventions": "^0.27.0",
1111
+ "@sentry/core": "11.8.0"
1087
1112
  },
1088
1113
  "engines": {
1089
1114
  "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0"
@@ -1093,27 +1118,27 @@
1093
1118
  }
1094
1119
  },
1095
1120
  "node_modules/@sentry/server-runtime-injection": {
1096
- "version": "11.4.0",
1097
- "resolved": "https://registry.npmjs.org/@sentry/server-runtime-injection/-/server-runtime-injection-11.4.0.tgz",
1098
- "integrity": "sha512-8IaiqIJuJySztjxdC0UXP3lHlBAr8HsI8kUMEDV7REYhgmz2+B4BmKyyvaZfeasJlAvzkvSOLZ/Xco7kPikZdQ==",
1121
+ "version": "11.8.0",
1122
+ "resolved": "https://registry.npmjs.org/@sentry/server-runtime-injection/-/server-runtime-injection-11.8.0.tgz",
1123
+ "integrity": "sha512-4uB4E8+9ni5NcJ463aYK/+n1hTMw/vSt4s+AkQ84CKigRDwv2FNNnD1AWpipWNj/xEQ6eQbUitGl7wg7/Ri8Pg==",
1099
1124
  "license": "MIT",
1100
1125
  "dependencies": {
1101
- "@sentry/core": "11.4.0",
1102
- "@sentry/server-utils": "11.4.0"
1126
+ "@sentry/core": "11.8.0",
1127
+ "@sentry/server-utils": "11.8.0"
1103
1128
  },
1104
1129
  "engines": {
1105
1130
  "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0"
1106
1131
  }
1107
1132
  },
1108
1133
  "node_modules/@sentry/server-utils": {
1109
- "version": "11.4.0",
1110
- "resolved": "https://registry.npmjs.org/@sentry/server-utils/-/server-utils-11.4.0.tgz",
1111
- "integrity": "sha512-GA4RvjRXyKFawD7Zi5AlgHPvm9Q8d9VeWer6g9qj4mDavi+VuNFhRmhgsv1qU1l3hlUeMYCpbKhpDOaOH/TcoQ==",
1134
+ "version": "11.8.0",
1135
+ "resolved": "https://registry.npmjs.org/@sentry/server-utils/-/server-utils-11.8.0.tgz",
1136
+ "integrity": "sha512-2MRFz1rXxnuDGafl6o/itwKHZeZn5hUZYeXNbQK012uO/a2pXhEc8Zh3FeO5L4tjGrIWHx419saAFnKwbVC2IQ==",
1112
1137
  "license": "MIT",
1113
1138
  "dependencies": {
1114
1139
  "@opentelemetry/api": "^1.9.1",
1115
- "@sentry/conventions": "^0.25.0",
1116
- "@sentry/core": "11.4.0"
1140
+ "@sentry/conventions": "^0.27.0",
1141
+ "@sentry/core": "11.8.0"
1117
1142
  },
1118
1143
  "engines": {
1119
1144
  "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0"
@@ -1399,9 +1424,9 @@
1399
1424
  }
1400
1425
  },
1401
1426
  "node_modules/drizzle-orm": {
1402
- "version": "0.45.3",
1403
- "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.3.tgz",
1404
- "integrity": "sha512-CAloER21cDdcgZ1OmjrZX82EeRNsdP+y0onX/eyoYDaYzki/adrAa91lzM5jWh7zZPK4qUbw/6LGv9J+A+uizA==",
1427
+ "version": "0.45.4",
1428
+ "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.4.tgz",
1429
+ "integrity": "sha512-NiM1YZpJ2UxyeE67DpDDZ+Tsw4YLX3trSlkNT7Tm9eKoUNb1LvaAaQzXgYQsWs+AQ9TUmmwMmBOCXuqqxUWKKQ==",
1405
1430
  "license": "Apache-2.0",
1406
1431
  "peerDependencies": {
1407
1432
  "@aws-sdk/client-rds-data": ">=3",
@@ -1687,9 +1712,9 @@
1687
1712
  }
1688
1713
  },
1689
1714
  "node_modules/express-rate-limit": {
1690
- "version": "8.7.0",
1691
- "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz",
1692
- "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==",
1715
+ "version": "8.7.1",
1716
+ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.1.tgz",
1717
+ "integrity": "sha512-2FXbqkygil3mZiXzEtM0IjE/UBmBNot0J1GEVrJT4JbrvwOSoEtqtO+wHbiMQELWe1eNvHoYRg2dkjOid+6JkA==",
1693
1718
  "license": "MIT",
1694
1719
  "dependencies": {
1695
1720
  "debug": "^4.4.3",
@@ -2021,6 +2046,12 @@
2021
2046
  "node": "20 || >=22"
2022
2047
  }
2023
2048
  },
2049
+ "node_modules/lucide-static": {
2050
+ "version": "1.59.0",
2051
+ "resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.59.0.tgz",
2052
+ "integrity": "sha512-MuHZYaXbHgzN2QO6D3OM19bj09A+8GJiFSdXIMlbnLLEGQQ9+NRH2KaCxuivevD3Y23vM74kV17XBifgtqhfEg==",
2053
+ "license": "ISC"
2054
+ },
2024
2055
  "node_modules/magic-string": {
2025
2056
  "version": "1.4.3",
2026
2057
  "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.34",
3
+ "version": "2026.10.36",
4
4
  "description": "Invoicing, bill entry and a financial dashboard for labs, with Seazona and Xero integrations.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -32,11 +32,12 @@
32
32
  "start": "node server/index.js",
33
33
  "test": "node --test --test-concurrency=1 test/*.test.js",
34
34
  "changelog:check": "node scripts/release/changelog.mjs check",
35
+ "release:stable": "node scripts/release/release-stable.mjs",
35
36
  "theme:contrast": "node scripts/theme-contrast.mjs",
36
37
  "db:generate": "drizzle-kit generate && node scripts/db-guard-migrations.js"
37
38
  },
38
39
  "dependencies": {
39
- "@clerk/backend": "3.23.2",
40
+ "@clerk/backend": "3.25.0",
40
41
  "@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
41
42
  "@fontsource-variable/dm-sans": "5.3.0",
42
43
  "@fontsource-variable/geist": "5.3.0",
@@ -48,10 +49,11 @@
48
49
  "@fontsource-variable/playfair-display": "5.3.0",
49
50
  "@fontsource-variable/plus-jakarta-sans": "5.3.0",
50
51
  "@fontsource-variable/space-grotesk": "5.3.0",
51
- "@sentry/node": "11.4.0",
52
- "drizzle-orm": "0.45.3",
52
+ "@sentry/node": "11.8.0",
53
+ "drizzle-orm": "0.45.4",
53
54
  "express": "5.3.0",
54
- "express-rate-limit": "8.7.0",
55
+ "express-rate-limit": "8.7.1",
56
+ "lucide-static": "1.59.0",
55
57
  "pdfkit": "0.20.2",
56
58
  "pg": "8.23.1"
57
59
  },
@@ -1,6 +1,6 @@
1
1
  import express from 'express';
2
2
  import {
3
- CUSTOM_FIELD_TYPES, GROUPABLE_TYPES, OPTION_COLORS, archiveField, createField, jobFieldHistory, listFields,
3
+ CUSTOM_FIELD_TYPES, GROUPABLE_TYPES, OPTION_COLORS, archiveField, choosablePeople, createField, jobFieldHistory, listFields,
4
4
  listJobFieldValues, orderFields, setJobFieldValue, updateField,
5
5
  } from '../core/custom-fields.js';
6
6
  import { FlowError } from '../core/errors.js';
@@ -8,7 +8,8 @@ import { resolveOrganisation } from './invoicing.js';
8
8
 
9
9
  // Custom fields for jobs (server/core/custom-fields.js, docs/CUSTOM_FIELDS.md).
10
10
  //
11
- // GET /api/custom-fields[?archived=1] the lab's fields, and the work systems' own fields (read only)
11
+ // GET /api/custom-fields[?archived=1] the lab's fields, the work systems' own fields (read only),
12
+ // and the people a person field can name
12
13
  // POST /api/custom-fields { name, type[, options] } owner or admin: make a field
13
14
  // PUT /api/custom-fields/order { ids } owner or admin: the field order
14
15
  // PUT /api/custom-fields/:id { name?, options? } owner or admin: rename, change options
@@ -70,8 +71,8 @@ export function createCustomFieldsRouter({ db, personAuth, work = () => [], log
70
71
  router.get('/', ...signedIn, handle(async (req, res) => {
71
72
  const organisationId = await organisationOf(req);
72
73
  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 });
74
+ const [fields, system, chosen] = await Promise.all([listFields(db, organisationId, { archived }), systemFields(), choosablePeople(db)]);
75
+ res.json({ ok: true, fields, systemFields: system, people: chosen, types: CUSTOM_FIELD_TYPES, groupable: GROUPABLE_TYPES, colors: OPTION_COLORS });
75
76
  }));
76
77
 
77
78
  router.post('/', ...manager, handle(async (req, res) => {
@@ -21,7 +21,9 @@ import { jobReference } from '../core/references.js';
21
21
  import {
22
22
  acceptFindings, archivePriceItem, checkPrices, createPriceItem, linkPriceItem, readCatalogues, readPriceList, unlinkPriceItem, updatePriceItem,
23
23
  } from '../core/price-list.js';
24
- import { draftInvoiceForObligation, explainObligation, listObligations, setReady, useSourceLines } from '../core/ledger.js';
24
+ import {
25
+ draftInvoiceForObligation, explainObligation, jobChangeFor, listObligations, setReady, useSourceLines,
26
+ } from '../core/ledger.js';
25
27
  import { isHistoryHeld } from '../core/history.js';
26
28
  import { invoicePdfDocument, pdfFields, pdfFilename, renderInvoicePdf } from '../core/invoice-pdf.js';
27
29
  import { invoiceView, invoiceViews, markNotReady, markReady, sendAgain, sendAllReady } from '../core/sending.js';
@@ -234,6 +236,15 @@ const jobOf = (o) => (o ? {
234
236
  id: o.id, sourceSystem: o.sourceSystem, sourceJobRef: o.sourceJobRef, caseRef: o.caseRef, practitioner: o.practitioner, shippedOn: o.shippedOn,
235
237
  } : null);
236
238
 
239
+ // A job with its work system's name and its link in that system's web app
240
+ // (jobLink: no live call), for the Linked column.
241
+ function withLink(job, systems) {
242
+ if (!job) return null;
243
+ const source = systems.find((a) => a.id === job.sourceSystem) ?? null;
244
+ const link = typeof source?.capabilities.jobLink === 'function' ? source.capabilities.jobLink({ sourceJobRef: job.sourceJobRef }) : null;
245
+ return { ...job, systemName: source?.name ?? job.sourceSystem, link: link ?? null };
246
+ }
247
+
237
248
  async function jobsByInvoice(db, organisationId, invoiceIds) {
238
249
  if (!invoiceIds.length) return new Map();
239
250
  const rows = await db.select().from(billingObligations)
@@ -241,15 +252,37 @@ async function jobsByInvoice(db, organisationId, invoiceIds) {
241
252
  return new Map(rows.map((o) => [o.invoiceId, jobOf(o)]));
242
253
  }
243
254
 
244
- async function invoiceDetail(db, organisationId, invoiceId, accounting = null) {
255
+ // The job changed after its invoice was made (ledger.js, jobChangeFor): what
256
+ // changed, with customer names, and the work system's link to the job.
257
+ async function jobChangeDetail(db, organisationId, invoiceId, work) {
258
+ const change = await jobChangeFor(db, organisationId, invoiceId);
259
+ if (!change) return null;
260
+ let contact = null;
261
+ if (change.contact) {
262
+ const ids = [change.contact.was, change.contact.now].filter(Boolean);
263
+ const rows = ids.length ? await db.select({ id: contacts.id, name: contacts.name }).from(contacts)
264
+ .where(and(eq(contacts.organisationId, organisationId), inArray(contacts.id, ids))) : [];
265
+ const nameOf = (id) => rows.find((r) => r.id === id)?.name ?? null;
266
+ contact = { was: nameOf(change.contact.was), now: nameOf(change.contact.now) };
267
+ }
268
+ const source = work.find((a) => a.id === change.sourceSystem) ?? null;
269
+ const link = typeof source?.capabilities.jobLink === 'function'
270
+ ? source.capabilities.jobLink({ sourceJobRef: change.sourceJobRef, sourceInvoiceRef: change.sourceInvoiceRef })
271
+ : null;
272
+ return { ...change, contact, systemName: source?.name ?? change.sourceSystem, link: link ?? null };
273
+ }
274
+
275
+ async function invoiceDetail(db, organisationId, invoiceId, accounting = null, work = []) {
245
276
  const invoice = await getInvoice(db, organisationId, invoiceId);
246
- const [[contact], versions, jobs] = await Promise.all([
277
+ const [[contact], versions, jobs, jobChange] = await Promise.all([
247
278
  db.select({ id: contacts.id, name: contacts.name, email: contacts.email }).from(contacts)
248
279
  .where(and(eq(contacts.organisationId, organisationId), eq(contacts.id, invoice.contactId))),
249
280
  listInvoiceVersions(db, organisationId, invoiceId),
250
281
  jobsByInvoice(db, organisationId, [invoiceId]),
282
+ jobChangeDetail(db, organisationId, invoiceId, work),
251
283
  ]);
252
284
  return {
285
+ jobChange,
253
286
  ...invoice,
254
287
  ...(await invoiceView(db, organisationId, invoice, { accounting })),
255
288
  // Where a draft goes when it is sent, and how: this customer's send mode.
@@ -319,6 +352,7 @@ export function createInvoicingRouter({
319
352
  return { rows, jobs: await jobsByInvoice(db, organisationId, rows.map((r) => r.invoice.id)) };
320
353
  })();
321
354
  const [views, counted, { rows, jobs }, mode] = await Promise.all([checking, counting, listing, sendMode]);
355
+ const systems = work();
322
356
  const counts = Object.fromEntries(INVOICE_STATUSES.map((s) => [s, 0]));
323
357
  for (const { status: s, n } of counted) counts[s] = n;
324
358
  counts.to_check = views.toCheck.length;
@@ -331,7 +365,7 @@ export function createInvoicingRouter({
331
365
  invoices: rows.map(({ invoice, contactName }) => ({
332
366
  ...invoice,
333
367
  contactName,
334
- job: jobs.get(invoice.id) ?? null,
368
+ job: withLink(jobs.get(invoice.id) ?? null, systems),
335
369
  view: views.problems.has(invoice.id) ? 'problem' : ready.has(invoice.id) ? 'ready' : toCheck.has(invoice.id) ? 'to_check'
336
370
  : views.sending.has(invoice.id) ? 'sending' : null,
337
371
  problems: views.problems.get(invoice.id) ?? [],
@@ -339,6 +373,8 @@ export function createInvoicingRouter({
339
373
  counts,
340
374
  sendsTo: system?.name ?? null,
341
375
  sendMode: mode,
376
+ // The Linked column: one icon for each connected system.
377
+ linksTo: [...systems.map((a) => ({ id: a.id, name: a.name, kind: 'work' })), ...(system ? [{ id: system.id, name: system.name, kind: 'accounting' }] : [])],
342
378
  });
343
379
  }));
344
380
 
@@ -386,7 +422,7 @@ export function createInvoicingRouter({
386
422
  router.get('/invoices/:id', ...signedIn, handle(log, async (req, res) => {
387
423
  const invoiceId = pathId(req.params.id, 'invoice');
388
424
  const organisationId = await organisationOf(req);
389
- res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting()) });
425
+ res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting(), work()) });
390
426
  }));
391
427
 
392
428
  router.get('/invoices/:id/pdf', ...signedIn, handle(log, async (req, res) => {
@@ -415,7 +451,7 @@ export function createInvoicingRouter({
415
451
  lines: body.lines === undefined ? [] : readLines(body.lines),
416
452
  });
417
453
  log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} made draft invoice ${draft.id}`);
418
- res.status(201).json({ ok: true, invoice: await invoiceDetail(db, organisationId, draft.id, accounting()) });
454
+ res.status(201).json({ ok: true, invoice: await invoiceDetail(db, organisationId, draft.id, accounting(), work()) });
419
455
  }));
420
456
 
421
457
  router.patch('/invoices/:id', ...signedIn, handle(log, async (req, res) => {
@@ -429,16 +465,22 @@ export function createInvoicingRouter({
429
465
  if (body.lines !== undefined) changes.lines = readLines(body.lines);
430
466
  if (!Object.keys(changes).length) throw invalid('nothing to change: send contactId, reference, dueDate or lines');
431
467
  await updateDraftInvoice(db, organisationId, invoiceId, changes);
432
- res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting()) });
468
+ res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting(), work()) });
433
469
  }));
434
470
 
435
471
  router.post('/invoices/:id/issue', ...manager, handle(log, async (req, res) => {
436
472
  const invoiceId = pathId(req.params.id, 'invoice');
437
473
  const organisationId = await organisationOf(req);
438
474
  const issueDate = optionalDate(req.body?.issueDate, 'issueDate') ?? today();
475
+ // A draft whose job changed after it was made waits for a person to fix
476
+ // the job at its source or adopt the change (ledger.js), as Move to
477
+ // Ready and Send do.
478
+ const { problems } = await invoiceView(db, organisationId, await getInvoice(db, organisationId, invoiceId));
479
+ const held = problems.find((p) => p.code === 'job_review');
480
+ if (held) throw new FlowError('not_ready', `${held.message} Nothing was finalised.`);
439
481
  const issued = await issueInvoice(db, organisationId, invoiceId, { issuedBy: `person:${req.person.id}`, issueDate });
440
482
  log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} issued invoice ${issued.number} (version ${issued.issuedVersion})`);
441
- res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting()) });
483
+ res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting(), work()) });
442
484
  }));
443
485
 
444
486
  router.post('/invoices/:id/revise', ...manager, handle(log, async (req, res) => {
@@ -446,7 +488,7 @@ export function createInvoicingRouter({
446
488
  const organisationId = await organisationOf(req);
447
489
  await reviseInvoice(db, organisationId, invoiceId);
448
490
  log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} opened invoice ${invoiceId} for revision`);
449
- res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting()) });
491
+ res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting(), work()) });
450
492
  }));
451
493
 
452
494
  router.post('/invoices/:id/void', ...manager, handle(log, async (req, res) => {
@@ -454,7 +496,7 @@ export function createInvoicingRouter({
454
496
  const organisationId = await organisationOf(req);
455
497
  await voidInvoice(db, organisationId, invoiceId);
456
498
  log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} voided invoice ${invoiceId}`);
457
- res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting()) });
499
+ res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting(), work()) });
458
500
  }));
459
501
 
460
502
  router.get('/contacts', ...signedIn, handle(log, async (req, res) => {
@@ -525,7 +567,7 @@ export function createInvoicingRouter({
525
567
  const reference = obligation ? await jobReference(db, organisationId, obligation, { template, work: source }) : null;
526
568
  const { invoice } = await draftInvoiceForObligation(db, organisationId, obligationId, { liveCheck, actor: `person:${req.person.id}`, reference });
527
569
  log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} drafted invoice ${invoice.id} from job ${obligationId}`);
528
- res.status(201).json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoice.id) });
570
+ res.status(201).json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoice.id, accounting(), work()) });
529
571
  }));
530
572
 
531
573
  // "Use new lines": the job changed in the work system after its draft was
@@ -534,7 +576,7 @@ export function createInvoicingRouter({
534
576
  const obligationId = pathId(req.params.id, 'job');
535
577
  const organisationId = await organisationOf(req);
536
578
  const { invoice } = await useSourceLines(db, organisationId, obligationId, { actor: `person:${req.person.id}` });
537
- res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoice.id) });
579
+ res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoice.id, accounting(), work()) });
538
580
  }));
539
581
 
540
582
  router.post('/jobs/:id/ready', ...signedIn, handle(log, async (req, res) => {
@@ -2,6 +2,7 @@ import { and, asc, eq, isNull, ne, sql } from 'drizzle-orm';
2
2
  import { people, PERSON_ROLES } from '../db/schema.js';
3
3
  import { FlowError } from '../core/errors.js';
4
4
  import { isThemeFamily, normalizeAccent } from '../theme/themes.js';
5
+ import { normaliseJobsView } from '../ui/job-view.js';
5
6
  import { isPlainEmail } from './settings.js';
6
7
 
7
8
  // The people who may sign in, and their roles. Only an owner changes this
@@ -50,6 +51,40 @@ export async function setPersonAppearance(db, { id, appearance }) {
50
51
  return row.appearance;
51
52
  }
52
53
 
54
+ // The pages a person can keep their own view of, each with its check.
55
+ // Each page is named in a switch, never looked up by the name a request
56
+ // sends, so a name such as "constructor" can only be refused.
57
+ function viewCheck(page) {
58
+ switch (page) {
59
+ case 'jobs': return normaliseJobsView;
60
+ default: return null;
61
+ }
62
+ }
63
+ const VIEW_PAGE_NAMES = ['jobs'];
64
+
65
+ // A person's saved views as Flow sends them: only the known pages, each
66
+ // checked, so a view saved by an older Flow still loads.
67
+ export function normaliseViews(input) {
68
+ const value = input && typeof input === 'object' ? input : {};
69
+ const out = {};
70
+ for (const page of VIEW_PAGE_NAMES) if (value[page]) out[page] = viewCheck(page)(value[page]);
71
+ return out;
72
+ }
73
+
74
+ // Each person keeps their own view of a page; saving one page leaves the
75
+ // others as they were.
76
+ export async function setPersonView(db, { id, page, view }) {
77
+ const normalise = viewCheck(page);
78
+ if (typeof normalise !== 'function') throw new FlowError('not_found', 'no such page');
79
+ const value = normalise(view);
80
+ const [row] = await db.update(people)
81
+ .set({ views: sql`coalesce(${people.views}, '{}'::jsonb) || ${JSON.stringify({ [page]: value })}::jsonb`, updatedAt: new Date() })
82
+ .where(eq(people.id, id))
83
+ .returning();
84
+ if (!row) throw new FlowError('not_found', 'person not found');
85
+ return value;
86
+ }
87
+
53
88
  export async function findPersonById(db, id) {
54
89
  const [row] = await db.select().from(people).where(eq(people.id, id));
55
90
  return row ?? null;
@@ -8,9 +8,11 @@ import {
8
8
  listPeople,
9
9
  normaliseEmail,
10
10
  normaliseAppearance,
11
+ normaliseViews,
11
12
  publicPerson,
12
13
  removePerson,
13
14
  setPersonAppearance,
15
+ setPersonView,
14
16
  updatePerson,
15
17
  } from './people.js';
16
18
  import { isPlainEmail } from './settings.js';
@@ -99,6 +101,7 @@ export function createSignInRouter({ settings, db, clerk, personAuth, defaultFam
99
101
  body.ownerActions = { allowed: Boolean(check.verified), by: check.by ?? null };
100
102
  }
101
103
  body.appearance = req.person.appearance ? normaliseAppearance(req.person.appearance) : null;
104
+ body.views = normaliseViews(req.person.views);
102
105
  res.json(body);
103
106
  });
104
107
 
@@ -112,6 +115,17 @@ export function createSignInRouter({ settings, db, clerk, personAuth, defaultFam
112
115
  }
113
116
  });
114
117
 
118
+ // Each person sets their own view of a page (how Jobs groups, sorts and
119
+ // filters); it follows their account.
120
+ router.put('/api/me/views/:page', requireSession, express.json({ limit: '8kb' }), async (req, res) => {
121
+ try {
122
+ const view = await setPersonView(db, { id: req.person.id, page: req.params.page, view: req.body });
123
+ res.json({ ok: true, view });
124
+ } catch (error) {
125
+ sendError(res, error, log);
126
+ }
127
+ });
128
+
115
129
  // Only an owner manages people.
116
130
  const people = express.Router();
117
131
  people.use(requireSession, requireOwner, express.json({ limit: '16kb' }));
@@ -286,6 +286,14 @@ export async function listJobFieldValues(db, organisationId, { jobs = null } = {
286
286
  return rows.filter((r) => !wanted || wanted.has(`${r.sourceSystem}\u0000${r.sourceJobRef}`)).map(publicValue);
287
287
  }
288
288
 
289
+ // The people a person field can name: every active person, by id and name
290
+ // (the part of the email before @ when they have no name), A to Z.
291
+ export async function choosablePeople(db) {
292
+ const rows = await db.select({ id: people.id, name: people.name, email: people.email }).from(people).where(eq(people.status, 'active'));
293
+ return rows.map((r) => ({ id: r.id, name: r.name?.trim() || r.email.replace(/@.*/, '') }))
294
+ .sort((a, b) => a.name.localeCompare(b.name));
295
+ }
296
+
289
297
  // What happened to one job's custom field values, newest first.
290
298
  export async function jobFieldHistory(db, organisationId, raw, { limit = 50 } = {}) {
291
299
  const job = readJob(raw);