@arcforgelabs/flow 2026.10.35 → 2026.10.37
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 +1 -0
- package/npm-shrinkwrap.json +81 -50
- package/package.json +6 -5
- package/server/api/invoicing.js +23 -2
- package/server/auth/clerk.js +35 -0
- package/server/auth/routes.js +45 -6
- package/server/core/host.js +24 -0
- package/server/index.js +9 -3
- package/server/integrations/seazona/index.js +1 -1
- package/server/integrations/seazona/settings.js +5 -2
- package/server/integrations/sentry/index.js +5 -2
- package/server/integrations/xero/index.js +3 -3
- package/server/integrations/xero/settings.js +6 -0
- package/server/ui/app.js +88 -23
- package/server/ui/bulk-bar.js +99 -0
- package/server/ui/custom-fields.js +223 -56
- package/server/ui/flow.css +158 -11
- package/server/ui/invoicing.js +291 -45
- package/server/ui/job-board.js +58 -34
- package/server/ui/jobs.js +27 -13
- package/server/ui/routes.js +11 -1
package/README.md
CHANGED
|
@@ -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.37",
|
|
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.37",
|
|
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.37",
|
|
4
4
|
"description": "Invoicing, bill entry and a financial dashboard for labs, with Seazona and Xero integrations.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"db:generate": "drizzle-kit generate && node scripts/db-guard-migrations.js"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@clerk/backend": "3.
|
|
40
|
+
"@clerk/backend": "3.25.0",
|
|
41
41
|
"@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
|
|
42
42
|
"@fontsource-variable/dm-sans": "5.3.0",
|
|
43
43
|
"@fontsource-variable/geist": "5.3.0",
|
|
@@ -49,10 +49,11 @@
|
|
|
49
49
|
"@fontsource-variable/playfair-display": "5.3.0",
|
|
50
50
|
"@fontsource-variable/plus-jakarta-sans": "5.3.0",
|
|
51
51
|
"@fontsource-variable/space-grotesk": "5.3.0",
|
|
52
|
-
"@sentry/node": "11.
|
|
53
|
-
"drizzle-orm": "0.45.
|
|
52
|
+
"@sentry/node": "11.8.0",
|
|
53
|
+
"drizzle-orm": "0.45.4",
|
|
54
54
|
"express": "5.3.0",
|
|
55
|
-
"express-rate-limit": "8.7.
|
|
55
|
+
"express-rate-limit": "8.7.1",
|
|
56
|
+
"lucide-static": "1.59.0",
|
|
56
57
|
"pdfkit": "0.20.2",
|
|
57
58
|
"pg": "8.23.1"
|
|
58
59
|
},
|
package/server/api/invoicing.js
CHANGED
|
@@ -236,6 +236,22 @@ const jobOf = (o) => (o ? {
|
|
|
236
236
|
id: o.id, sourceSystem: o.sourceSystem, sourceJobRef: o.sourceJobRef, caseRef: o.caseRef, practitioner: o.practitioner, shippedOn: o.shippedOn,
|
|
237
237
|
} : null);
|
|
238
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
|
+
|
|
248
|
+
// The connected systems an invoice can link to: work systems, then the
|
|
249
|
+
// accounting system.
|
|
250
|
+
const linkedSystems = (work, accounting) => [
|
|
251
|
+
...work.map((a) => ({ id: a.id, name: a.name, kind: 'work' })),
|
|
252
|
+
...(accounting ? [{ id: accounting.id, name: accounting.name, kind: 'accounting' }] : []),
|
|
253
|
+
];
|
|
254
|
+
|
|
239
255
|
async function jobsByInvoice(db, organisationId, invoiceIds) {
|
|
240
256
|
if (!invoiceIds.length) return new Map();
|
|
241
257
|
const rows = await db.select().from(billingObligations)
|
|
@@ -282,7 +298,9 @@ async function invoiceDetail(db, organisationId, invoiceId, accounting = null, w
|
|
|
282
298
|
? { mode: await sendModeFor(db, organisationId, invoice.contactId), otherCustomers: 0, ...(await accounting.capabilities.sendStatus({ organisationId })) }
|
|
283
299
|
: null,
|
|
284
300
|
contact: contact ?? null,
|
|
285
|
-
job: jobs.get(invoiceId) ?? null,
|
|
301
|
+
job: withLink(jobs.get(invoiceId) ?? null, work),
|
|
302
|
+
// The Linked card: each connected system, and this invoice's link there.
|
|
303
|
+
linksTo: linkedSystems(work, accounting),
|
|
286
304
|
versions: versions.map(({ version, issuedAt, issuedBy }) => ({ version, issuedAt, issuedBy })),
|
|
287
305
|
};
|
|
288
306
|
}
|
|
@@ -343,6 +361,7 @@ export function createInvoicingRouter({
|
|
|
343
361
|
return { rows, jobs: await jobsByInvoice(db, organisationId, rows.map((r) => r.invoice.id)) };
|
|
344
362
|
})();
|
|
345
363
|
const [views, counted, { rows, jobs }, mode] = await Promise.all([checking, counting, listing, sendMode]);
|
|
364
|
+
const systems = work();
|
|
346
365
|
const counts = Object.fromEntries(INVOICE_STATUSES.map((s) => [s, 0]));
|
|
347
366
|
for (const { status: s, n } of counted) counts[s] = n;
|
|
348
367
|
counts.to_check = views.toCheck.length;
|
|
@@ -355,7 +374,7 @@ export function createInvoicingRouter({
|
|
|
355
374
|
invoices: rows.map(({ invoice, contactName }) => ({
|
|
356
375
|
...invoice,
|
|
357
376
|
contactName,
|
|
358
|
-
job: jobs.get(invoice.id) ?? null,
|
|
377
|
+
job: withLink(jobs.get(invoice.id) ?? null, systems),
|
|
359
378
|
view: views.problems.has(invoice.id) ? 'problem' : ready.has(invoice.id) ? 'ready' : toCheck.has(invoice.id) ? 'to_check'
|
|
360
379
|
: views.sending.has(invoice.id) ? 'sending' : null,
|
|
361
380
|
problems: views.problems.get(invoice.id) ?? [],
|
|
@@ -363,6 +382,8 @@ export function createInvoicingRouter({
|
|
|
363
382
|
counts,
|
|
364
383
|
sendsTo: system?.name ?? null,
|
|
365
384
|
sendMode: mode,
|
|
385
|
+
// The Linked column: one icon for each connected system.
|
|
386
|
+
linksTo: linkedSystems(systems, system),
|
|
366
387
|
});
|
|
367
388
|
}));
|
|
368
389
|
|
package/server/auth/clerk.js
CHANGED
|
@@ -8,6 +8,9 @@ import { normaliseEmail } from './people.js';
|
|
|
8
8
|
// getUser(clerkUserId) the Clerk user (emails, external accounts)
|
|
9
9
|
// findUserIdByEmail(email) the Clerk user with this email, or null
|
|
10
10
|
// createUser({ email }) a Clerk account with no password, for an invitee
|
|
11
|
+
// deleteUser(clerkUserId) removes a Clerk account (an unused invitee's only)
|
|
12
|
+
// inviteByEmail({ email, redirectUrl })
|
|
13
|
+
// Clerk emails an invitation link to `email`
|
|
11
14
|
//
|
|
12
15
|
// The real one is loaded only when FLOW_AUTH=clerk.
|
|
13
16
|
export async function loadClerkAdapter({ secretKey, publishableKey, proxyUrl, authorizedParties = [] }) {
|
|
@@ -36,9 +39,41 @@ export async function loadClerkAdapter({ secretKey, publishableKey, proxyUrl, au
|
|
|
36
39
|
const user = await client.users.createUser({ emailAddress: [email], skipPasswordRequirement: true });
|
|
37
40
|
return user.id;
|
|
38
41
|
},
|
|
42
|
+
async deleteUser(clerkUserId) {
|
|
43
|
+
await client.users.deleteUser(clerkUserId);
|
|
44
|
+
},
|
|
45
|
+
// Clerk sends the email. Its link opens `redirectUrl` with a sign-up
|
|
46
|
+
// ticket; a pending invitation for the same address is replaced.
|
|
47
|
+
async inviteByEmail({ email, redirectUrl }) {
|
|
48
|
+
await client.invitations.createInvitation({ emailAddress: email, redirectUrl, notify: true, ignoreExisting: true });
|
|
49
|
+
},
|
|
39
50
|
};
|
|
40
51
|
}
|
|
41
52
|
|
|
53
|
+
// A Clerk account nobody has used: never signed in, no password and no
|
|
54
|
+
// connected account. Flow made it for an invitee (ensureClerkAccount).
|
|
55
|
+
export function unusedClerkAccount(clerkUser) {
|
|
56
|
+
return Boolean(clerkUser)
|
|
57
|
+
&& !clerkUser.lastSignInAt
|
|
58
|
+
&& clerkUser.passwordEnabled === false
|
|
59
|
+
&& !(Array.isArray(clerkUser.externalAccounts) && clerkUser.externalAccounts.length);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Has Clerk email an invitation to `email`. Clerk does not invite an address
|
|
63
|
+
// that already has an account, so an unused account Flow made earlier is
|
|
64
|
+
// removed first; the invitee's account is made when they take up the
|
|
65
|
+
// invitation. An address with a used account gets no email: that person can
|
|
66
|
+
// already sign in. Returns { emailed }.
|
|
67
|
+
export async function sendClerkInvitation(clerk, email, redirectUrl) {
|
|
68
|
+
const existing = await clerk.findUserIdByEmail(email);
|
|
69
|
+
if (existing) {
|
|
70
|
+
if (!unusedClerkAccount(await clerk.getUser(existing))) return { emailed: false };
|
|
71
|
+
await clerk.deleteUser(existing);
|
|
72
|
+
}
|
|
73
|
+
await clerk.inviteByEmail({ email, redirectUrl });
|
|
74
|
+
return { emailed: true };
|
|
75
|
+
}
|
|
76
|
+
|
|
42
77
|
// An invitee needs a Clerk account before their first sign-in, because with
|
|
43
78
|
// public sign-up off Flow's sign-in page never creates one. The account has
|
|
44
79
|
// the invitee's email (verified, since the backend made it) and no password;
|
package/server/auth/routes.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import express from 'express';
|
|
2
2
|
import { FlowError } from '../core/errors.js';
|
|
3
3
|
import { rateLimit } from '../rate-limit.js';
|
|
4
|
-
import { ensureClerkAccount } from './clerk.js';
|
|
4
|
+
import { ensureClerkAccount, sendClerkInvitation } from './clerk.js';
|
|
5
5
|
import {
|
|
6
6
|
findPersonByEmail,
|
|
7
|
+
findPersonById,
|
|
7
8
|
invitePerson,
|
|
8
9
|
listPeople,
|
|
9
10
|
normaliseEmail,
|
|
@@ -139,23 +140,61 @@ export function createSignInRouter({ settings, db, clerk, personAuth, defaultFam
|
|
|
139
140
|
}
|
|
140
141
|
});
|
|
141
142
|
|
|
142
|
-
//
|
|
143
|
-
|
|
143
|
+
// The invitation email's link opens this installation's sign-in page.
|
|
144
|
+
const signInUrl = (req) => `${req.protocol}://${req.get('host')}/sign-in`;
|
|
145
|
+
|
|
146
|
+
// Clerk emails the invitation. If Clerk cannot, Flow makes the invitee's
|
|
147
|
+
// Clerk account itself (with public sign-up off the sign-in page never
|
|
148
|
+
// creates one) and the owner sends the sign-in message by hand.
|
|
149
|
+
async function inviteWithClerk(req, email) {
|
|
150
|
+
try {
|
|
151
|
+
return await sendClerkInvitation(clerk, email, signInUrl(req));
|
|
152
|
+
} catch (error) {
|
|
153
|
+
log.error?.(`[people] Clerk invitation email failed: ${error.message}`);
|
|
154
|
+
}
|
|
155
|
+
await ensureClerkAccount(clerk, email);
|
|
156
|
+
return { emailed: false };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Invite someone.
|
|
144
160
|
people.post('/', async (req, res) => {
|
|
145
161
|
try {
|
|
146
162
|
const email = normaliseEmail(req.body?.email);
|
|
147
163
|
const role = req.body?.role;
|
|
148
164
|
if (!isPlainEmail(email)) throw new FlowError('invalid', 'email must be a plain (ASCII) email address');
|
|
149
165
|
if (await findPersonByEmail(db, email)) throw new FlowError('conflict', 'that email address is already on the people list');
|
|
166
|
+
let invite;
|
|
150
167
|
try {
|
|
151
|
-
await
|
|
168
|
+
invite = await inviteWithClerk(req, email);
|
|
152
169
|
} catch (error) {
|
|
153
170
|
log.error?.(`[people] Clerk account for an invitation failed: ${error.message}`);
|
|
154
171
|
return res.status(503).json({ ok: false, code: 'sign_in_unavailable', error: 'Flow could not prepare the sign-in account. Try again shortly.' });
|
|
155
172
|
}
|
|
156
173
|
const person = await invitePerson(db, { email, name: req.body?.name ?? null, role, invitedBy: req.person.id });
|
|
157
|
-
log.info?.(`[people] ${req.person.id} invited ${person.id} as ${person.role}`);
|
|
158
|
-
res.status(201).json({ ok: true, person: publicPerson(person) });
|
|
174
|
+
log.info?.(`[people] ${req.person.id} invited ${person.id} as ${person.role} (${invite.emailed ? 'emailed' : 'not emailed'})`);
|
|
175
|
+
res.status(201).json({ ok: true, person: publicPerson(person), invite });
|
|
176
|
+
} catch (error) {
|
|
177
|
+
sendError(res, error, log);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// Email an invited person's invitation again. Only for someone who has
|
|
182
|
+
// not signed in yet.
|
|
183
|
+
people.post('/:id/invite', async (req, res) => {
|
|
184
|
+
try {
|
|
185
|
+
const person = await findPersonById(db, req.params.id);
|
|
186
|
+
if (!person) throw new FlowError('not_found', 'person not found');
|
|
187
|
+
if (person.clerkUserId || person.status !== 'active') throw new FlowError('conflict', 'only an invited person who has not signed in yet gets an invitation email');
|
|
188
|
+
let invite;
|
|
189
|
+
try {
|
|
190
|
+
invite = await sendClerkInvitation(clerk, person.email, signInUrl(req));
|
|
191
|
+
} catch (error) {
|
|
192
|
+
log.error?.(`[people] Clerk invitation email failed: ${error.message}`);
|
|
193
|
+
return res.status(503).json({ ok: false, code: 'sign_in_unavailable', error: 'Clerk could not send the invitation email. Try again shortly.' });
|
|
194
|
+
}
|
|
195
|
+
if (!invite.emailed) throw new FlowError('conflict', 'they already have a sign-in account: they can sign in now');
|
|
196
|
+
log.info?.(`[people] ${req.person.id} emailed the invitation for ${person.id} again`);
|
|
197
|
+
res.json({ ok: true, invite });
|
|
159
198
|
} catch (error) {
|
|
160
199
|
sendError(res, error, log);
|
|
161
200
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Where this copy of Flow runs. A Replit workspace (the editor's Run button)
|
|
2
|
+
// shares its secrets with the published app, so without a rule it would sync,
|
|
3
|
+
// draft and send next to the live copy: twice the calls to Xero and Seazona,
|
|
4
|
+
// and a second sender. FLOW_BACKGROUND_JOBS decides; unset, it is off in a
|
|
5
|
+
// Replit workspace and on everywhere else (docs/SELF_HOSTING.md).
|
|
6
|
+
|
|
7
|
+
const read = (env, name) => String(env?.[name] ?? '').trim();
|
|
8
|
+
|
|
9
|
+
// Replit sets REPL_ID in the workspace and REPLIT_DEPLOYMENT on the published
|
|
10
|
+
// app only.
|
|
11
|
+
export function isReplitWorkspace(env = process.env) {
|
|
12
|
+
return Boolean(read(env, 'REPL_ID')) && !read(env, 'REPLIT_DEPLOYMENT');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Whether this copy runs the jobs that call or write to other systems: the
|
|
16
|
+
// Seazona sync, the Xero read, the pipeline, the daily reconcile and the send
|
|
17
|
+
// worker. Off, it also never writes to Xero.
|
|
18
|
+
export function backgroundJobsOn(env = process.env) {
|
|
19
|
+
const value = read(env, 'FLOW_BACKGROUND_JOBS').toLowerCase();
|
|
20
|
+
if (value === 'on') return true;
|
|
21
|
+
if (value === 'off') return false;
|
|
22
|
+
if (value) throw new Error('FLOW_BACKGROUND_JOBS must be "on", "off" or unset');
|
|
23
|
+
return !isReplitWorkspace(env);
|
|
24
|
+
}
|
package/server/index.js
CHANGED
|
@@ -13,6 +13,7 @@ import { createMonitoredLog } from './core/monitoring.js';
|
|
|
13
13
|
import { createTracing, traceQueries } from './core/tracing.js';
|
|
14
14
|
import { createPipelineWorker } from './core/pipeline.js';
|
|
15
15
|
import { createReconcileWorker } from './core/reconcile.js';
|
|
16
|
+
import { backgroundJobsOn } from './core/host.js';
|
|
16
17
|
import { createSendWorker } from './core/sending.js';
|
|
17
18
|
import { resolveSecretsKey } from './core/secrets.js';
|
|
18
19
|
import { adapters as shippedAdapters } from './integrations/adapters.js';
|
|
@@ -96,20 +97,25 @@ try {
|
|
|
96
97
|
const integrationsOn = integrations.list().filter((a) => a.enabled).map((a) => `${a.id}${a.state === 'ready' ? '' : ` (${a.state})`}`);
|
|
97
98
|
console.log(`[integrations] on: ${integrationsOn.join(', ') || 'none'}; secrets key ${secrets ? `from ${secrets.source}` : 'not set (FLOW_SECRETS_KEY)'}`);
|
|
98
99
|
|
|
100
|
+
// Background jobs run only where FLOW_BACKGROUND_JOBS allows: never in a
|
|
101
|
+
// Replit workspace, which shares the published app's secrets
|
|
102
|
+
// (server/core/host.js).
|
|
103
|
+
const jobsOn = backgroundJobsOn(process.env);
|
|
104
|
+
if (!jobsOn) console.log('[flow] background jobs off (FLOW_BACKGROUND_JOBS): no sync, pipeline, reconcile or sending in this copy');
|
|
99
105
|
// The pipeline (server/core/pipeline.js) joins what the integrations copy into
|
|
100
106
|
// Flow: jobs into the ledger, and each job to its accounting invoice. It runs
|
|
101
107
|
// only while an integration that feeds it is on, and writes to no other
|
|
102
108
|
// system. FLOW_PIPELINE=off stops it.
|
|
103
|
-
if (process.env.FLOW_PIPELINE !== 'off') {
|
|
109
|
+
if (jobsOn && process.env.FLOW_PIPELINE !== 'off') {
|
|
104
110
|
createPipelineWorker({ db: database.db, integrations, log }).start();
|
|
105
111
|
}
|
|
106
112
|
// The daily reconcile (server/core/reconcile.js) compares Flow's own copies
|
|
107
113
|
// and opens a review item for a day that does not agree. It never calls or
|
|
108
114
|
// writes to another system.
|
|
109
|
-
createReconcileWorker({ db: database.db, integrations, log }).start();
|
|
115
|
+
if (jobsOn) createReconcileWorker({ db: database.db, integrations, log }).start();
|
|
110
116
|
// The send worker (server/core/sending.js) sends only what a person queued
|
|
111
117
|
// with Send all ready, to the accounting integration that is on.
|
|
112
|
-
createSendWorker({ db: database.db, integrations, log }).start();
|
|
118
|
+
if (jobsOn) createSendWorker({ db: database.db, integrations, log }).start();
|
|
113
119
|
|
|
114
120
|
let auth = null;
|
|
115
121
|
if (settings.mode === 'clerk') {
|
|
@@ -111,7 +111,7 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
|
|
|
111
111
|
const scheduler = settings.scheduled ? createSeazonaScheduler({ db, settings, run, now, log, timers }) : null;
|
|
112
112
|
runtime = { settings, db, client, run, scheduler };
|
|
113
113
|
scheduler?.start();
|
|
114
|
-
log.info?.(`[seazona] mode ${settings.mode}; background sync ${scheduler ? 'on' : 'off (live mode only)'}; ${settings.rate.requestsPerMinute} requests a minute at most`);
|
|
114
|
+
log.info?.(`[seazona] mode ${settings.mode}; background sync ${scheduler ? 'on' : settings.mode === 'live' ? 'off (FLOW_BACKGROUND_JOBS)' : 'off (live mode only)'}; ${settings.rate.requestsPerMinute} requests a minute at most`);
|
|
115
115
|
},
|
|
116
116
|
|
|
117
117
|
async stop() {
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
// replay recorded, synthetic responses from a file; no network at all.
|
|
9
9
|
// There is no default mode. Without one the adapter does nothing.
|
|
10
10
|
|
|
11
|
+
import { backgroundJobsOn } from '../../core/host.js';
|
|
12
|
+
|
|
11
13
|
export const MODES = Object.freeze(['live', 'sandbox', 'replay']);
|
|
12
14
|
|
|
13
15
|
// Seazona publishes 60 requests a minute per API client. Flow stays at half of
|
|
@@ -190,8 +192,9 @@ export function resolveSeazonaSettings(env = process.env) {
|
|
|
190
192
|
credentials: null,
|
|
191
193
|
replayFile: '',
|
|
192
194
|
rate,
|
|
193
|
-
// The scheduler starts only in live mode (issue #10)
|
|
194
|
-
|
|
195
|
+
// The scheduler starts only in live mode (issue #10), and only where
|
|
196
|
+
// background jobs run: never in a Replit workspace (server/core/host.js).
|
|
197
|
+
scheduled: mode === 'live' && backgroundJobsOn(env),
|
|
195
198
|
// The address of one order in Seazona's own screens, with {ref} for the
|
|
196
199
|
// order number, for "Open in Seazona" on a stage move. Optional; one
|
|
197
200
|
// without {ref} is ignored.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as SentryNode from '@sentry/node';
|
|
2
|
+
import { isReplitWorkspace } from '../../core/host.js';
|
|
2
3
|
import { scrubSpan } from '../../ui/monitoring.js';
|
|
3
4
|
|
|
4
5
|
// The Sentry adapter (docs/SENTRY.md). It sends the errors Flow logs, the
|
|
@@ -40,7 +41,7 @@ export const SENTRY_SETUP = {
|
|
|
40
41
|
},
|
|
41
42
|
fields: [
|
|
42
43
|
{ name: 'SENTRY_DSN', label: 'DSN', kind: 'url', help: 'From the Sentry project\'s Client Keys. Browsers see it, so it is not a secret.' },
|
|
43
|
-
{ name: 'SENTRY_ENVIRONMENT', label: 'Environment', help: 'How Sentry labels this installation, for example production. Empty: production.' },
|
|
44
|
+
{ name: 'SENTRY_ENVIRONMENT', label: 'Environment', help: 'How Sentry labels this installation, for example production. Empty: production, or workspace in a Replit workspace.' },
|
|
44
45
|
{ name: 'SENTRY_FEEDBACK', label: 'Report a problem button', kind: 'onoff', help: 'On: everyone signed in can send a report, with a screenshot, to this Sentry project.' },
|
|
45
46
|
{ name: 'SENTRY_TRACES_SAMPLE_RATE', label: 'Traces', help: 'The share of clicks, page loads and requests timed, from 0 (off) to 1 (all). Empty: 0.2.' },
|
|
46
47
|
],
|
|
@@ -50,7 +51,9 @@ export function readSentrySettings(env) {
|
|
|
50
51
|
const dsn = String(env.SENTRY_DSN ?? '').trim();
|
|
51
52
|
if (!dsn) throw new Error('SENTRY_DSN is not set. Enter the Sentry project\'s DSN in Settings, Integrations.');
|
|
52
53
|
if (!DSN_PATTERN.test(dsn)) throw new Error('SENTRY_DSN is not a Sentry DSN (https://<key>@<host>/<project id>).');
|
|
53
|
-
|
|
54
|
+
// A Replit workspace shares the published app's secrets; its errors are
|
|
55
|
+
// labelled apart so they never read as the live app's.
|
|
56
|
+
const environment = String(env.SENTRY_ENVIRONMENT ?? '').trim() || (isReplitWorkspace(env) ? 'workspace' : 'production');
|
|
54
57
|
const feedback = String(env.SENTRY_FEEDBACK ?? 'on').trim() !== 'off';
|
|
55
58
|
const rate = String(env.SENTRY_TRACES_SAMPLE_RATE ?? '').trim();
|
|
56
59
|
const tracesSampleRate = rate === '' ? DEFAULT_TRACES_SAMPLE_RATE : Number(rate);
|