@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 +2 -1
- package/npm-shrinkwrap.json +81 -50
- package/package.json +7 -5
- package/server/api/custom-fields.js +5 -4
- package/server/api/invoicing.js +54 -12
- package/server/auth/people.js +35 -0
- package/server/auth/routes.js +14 -0
- package/server/core/custom-fields.js +8 -0
- package/server/core/job-board.js +2 -0
- package/server/core/ledger.js +82 -2
- package/server/db/migrations/0019_people_views.sql +1 -0
- package/server/db/migrations/meta/0019_snapshot.json +4295 -0
- package/server/db/migrations/meta/_journal.json +7 -0
- package/server/db/schema.js +5 -1
- package/server/integrations/port.js +1 -0
- package/server/integrations/seazona/index.js +17 -5
- package/server/integrations/seazona/settings.js +24 -3
- package/server/integrations/seazona/stage-moves.js +10 -12
- package/server/release-channel.js +8 -6
- package/server/ui/custom-fields.js +223 -56
- package/server/ui/flow.css +155 -2
- package/server/ui/invoice-notes.js +1 -1
- package/server/ui/invoicing.js +160 -59
- 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 +481 -50
- package/server/ui/routes.js +11 -1
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,
|
|
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 |
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcforgelabs/flow",
|
|
3
|
-
"version": "2026.10.
|
|
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.
|
|
9
|
+
"version": "2026.10.36",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@clerk/backend": "3.
|
|
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.
|
|
25
|
-
"drizzle-orm": "0.45.
|
|
24
|
+
"@sentry/node": "11.8.0",
|
|
25
|
+
"drizzle-orm": "0.45.4",
|
|
26
26
|
"express": "5.3.0",
|
|
27
|
-
"express-rate-limit": "8.7.
|
|
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.
|
|
43
|
-
"resolved": "https://registry.npmjs.org/@clerk/backend/-/backend-3.
|
|
44
|
-
"integrity": "sha512-
|
|
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.
|
|
1008
|
-
"resolved": "https://registry.npmjs.org/@sentry/bundler-plugins/-/bundler-plugins-11.
|
|
1009
|
-
"integrity": "sha512-
|
|
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.
|
|
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.
|
|
1042
|
-
"resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.
|
|
1043
|
-
"integrity": "sha512-
|
|
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.
|
|
1051
|
-
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-11.
|
|
1052
|
-
"integrity": "sha512-
|
|
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.
|
|
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.
|
|
1063
|
-
"resolved": "https://registry.npmjs.org/@sentry/node/-/node-11.
|
|
1064
|
-
"integrity": "sha512-
|
|
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.
|
|
1069
|
-
"@sentry/conventions": "^0.
|
|
1070
|
-
"@sentry/core": "11.
|
|
1071
|
-
"@sentry/opentelemetry": "11.
|
|
1072
|
-
"@sentry/server-runtime-injection": "11.
|
|
1073
|
-
"@sentry/server-utils": "11.
|
|
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.
|
|
1081
|
-
"resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-11.
|
|
1082
|
-
"integrity": "sha512-
|
|
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.
|
|
1086
|
-
"@sentry/core": "11.
|
|
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.
|
|
1097
|
-
"resolved": "https://registry.npmjs.org/@sentry/server-runtime-injection/-/server-runtime-injection-11.
|
|
1098
|
-
"integrity": "sha512-
|
|
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.
|
|
1102
|
-
"@sentry/server-utils": "11.
|
|
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.
|
|
1110
|
-
"resolved": "https://registry.npmjs.org/@sentry/server-utils/-/server-utils-11.
|
|
1111
|
-
"integrity": "sha512-
|
|
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.
|
|
1116
|
-
"@sentry/core": "11.
|
|
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.
|
|
1403
|
-
"resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.
|
|
1404
|
-
"integrity": "sha512-
|
|
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.
|
|
1691
|
-
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.
|
|
1692
|
-
"integrity": "sha512-
|
|
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.
|
|
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.
|
|
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.
|
|
52
|
-
"drizzle-orm": "0.45.
|
|
52
|
+
"@sentry/node": "11.8.0",
|
|
53
|
+
"drizzle-orm": "0.45.4",
|
|
53
54
|
"express": "5.3.0",
|
|
54
|
-
"express-rate-limit": "8.7.
|
|
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,
|
|
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) => {
|
package/server/api/invoicing.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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) => {
|
package/server/auth/people.js
CHANGED
|
@@ -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;
|
package/server/auth/routes.js
CHANGED
|
@@ -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);
|