Merge branch 'dev' into feature/session-persistence-new
This commit is contained in:
@@ -6,6 +6,7 @@ COPY package*.json ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
COPY src ./src
|
||||
COPY scripts ./scripts
|
||||
|
||||
ENV PORT=8080
|
||||
EXPOSE 8080
|
||||
|
||||
66
backend/command-api/package-lock.json
generated
66
backend/command-api/package-lock.json
generated
@@ -8,6 +8,7 @@
|
||||
"name": "@krow/command-api",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@google-cloud/storage": "^7.19.0",
|
||||
"express": "^4.21.2",
|
||||
"firebase-admin": "^13.0.2",
|
||||
"pg": "^8.16.3",
|
||||
@@ -151,7 +152,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@google-cloud/paginator/-/paginator-5.0.2.tgz",
|
||||
"integrity": "sha512-DJS3s0OVH4zFDB1PzjxAsHqJT6sKVbRwwML0ZBP9PbU7Yebtu/7SWMRzvO2J3nUi9pRNITCfu4LJeooM2w4pjg==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"arrify": "^2.0.0",
|
||||
"extend": "^3.0.2"
|
||||
@@ -165,7 +165,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@google-cloud/projectify/-/projectify-4.0.0.tgz",
|
||||
"integrity": "sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
@@ -175,7 +174,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-4.0.0.tgz",
|
||||
"integrity": "sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
@@ -185,7 +183,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.19.0.tgz",
|
||||
"integrity": "sha512-n2FjE7NAOYyshogdc7KQOl/VZb4sneqPjWouSyia9CMDdMhRX5+RIbqalNmC7LOLzuLAN89VlF2HvG8na9G+zQ==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@google-cloud/paginator": "^5.0.0",
|
||||
"@google-cloud/projectify": "^4.0.0",
|
||||
@@ -212,7 +209,6 @@
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
@@ -398,7 +394,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz",
|
||||
"integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
@@ -407,8 +402,7 @@
|
||||
"version": "0.12.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz",
|
||||
"integrity": "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/jsonwebtoken": {
|
||||
"version": "9.0.10",
|
||||
@@ -447,7 +441,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.13.tgz",
|
||||
"integrity": "sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@types/caseless": "*",
|
||||
"@types/node": "*",
|
||||
@@ -459,15 +452,13 @@
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
|
||||
"integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/abort-controller": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
||||
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"event-target-shim": "^5.0.0"
|
||||
},
|
||||
@@ -534,7 +525,6 @@
|
||||
"resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz",
|
||||
"integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
@@ -551,7 +541,6 @@
|
||||
"resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz",
|
||||
"integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"retry": "0.13.1"
|
||||
}
|
||||
@@ -560,7 +549,6 @@
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/atomic-sleep": {
|
||||
@@ -708,7 +696,6 @@
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
@@ -783,7 +770,6 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
@@ -838,7 +824,6 @@
|
||||
"resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz",
|
||||
"integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"end-of-stream": "^1.4.1",
|
||||
"inherits": "^2.0.3",
|
||||
@@ -882,7 +867,6 @@
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"once": "^1.4.0"
|
||||
}
|
||||
@@ -921,7 +905,6 @@
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
@@ -963,7 +946,6 @@
|
||||
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
|
||||
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
@@ -1053,7 +1035,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"strnum": "^2.1.2"
|
||||
},
|
||||
@@ -1122,7 +1103,6 @@
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.5.tgz",
|
||||
"integrity": "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
@@ -1381,7 +1361,6 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
@@ -1419,8 +1398,7 @@
|
||||
"url": "https://patreon.com/mdevils"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
@@ -1453,7 +1431,6 @@
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz",
|
||||
"integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@tootallnate/once": "2",
|
||||
"agent-base": "6",
|
||||
@@ -1468,7 +1445,6 @@
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"debug": "4"
|
||||
},
|
||||
@@ -1481,7 +1457,6 @@
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
@@ -1498,8 +1473,7 @@
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
@@ -1822,7 +1796,6 @@
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
|
||||
"integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
"mime": "cli.js"
|
||||
},
|
||||
@@ -1942,7 +1915,6 @@
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||
"devOptional": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
@@ -1953,7 +1925,6 @@
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
||||
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"yocto-queue": "^0.1.0"
|
||||
},
|
||||
@@ -2273,7 +2244,6 @@
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
@@ -2307,7 +2277,6 @@
|
||||
"resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
|
||||
"integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
@@ -2317,7 +2286,6 @@
|
||||
"resolved": "https://registry.npmjs.org/retry-request/-/retry-request-7.0.2.tgz",
|
||||
"integrity": "sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@types/request": "^2.48.8",
|
||||
"extend": "^3.0.2",
|
||||
@@ -2541,7 +2509,6 @@
|
||||
"resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz",
|
||||
"integrity": "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"stubs": "^3.0.0"
|
||||
}
|
||||
@@ -2550,15 +2517,13 @@
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz",
|
||||
"integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
@@ -2601,15 +2566,13 @@
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stubs": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz",
|
||||
"integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/superagent": {
|
||||
"version": "10.3.0",
|
||||
@@ -2717,7 +2680,6 @@
|
||||
"resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz",
|
||||
"integrity": "sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"http-proxy-agent": "^5.0.0",
|
||||
"https-proxy-agent": "^5.0.0",
|
||||
@@ -2734,7 +2696,6 @@
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"debug": "4"
|
||||
},
|
||||
@@ -2747,7 +2708,6 @@
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
@@ -2765,7 +2725,6 @@
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"agent-base": "6",
|
||||
"debug": "4"
|
||||
@@ -2778,8 +2737,7 @@
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/teeny-request/node_modules/uuid": {
|
||||
"version": "9.0.1",
|
||||
@@ -2790,7 +2748,6 @@
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
@@ -2857,8 +2814,7 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
"version": "1.0.1",
|
||||
@@ -2952,7 +2908,6 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"devOptional": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
@@ -3014,7 +2969,6 @@
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
|
||||
@@ -9,9 +9,14 @@
|
||||
"scripts": {
|
||||
"start": "node src/server.js",
|
||||
"test": "node --test",
|
||||
"migrate:idempotency": "node scripts/migrate-idempotency.mjs"
|
||||
"dispatch:notifications": "node scripts/dispatch-notifications.mjs",
|
||||
"migrate:idempotency": "node scripts/migrate-idempotency.mjs",
|
||||
"migrate:v2-schema": "node scripts/migrate-v2-schema.mjs",
|
||||
"seed:v2-demo": "node scripts/seed-v2-demo-data.mjs",
|
||||
"smoke:v2-live": "node scripts/live-smoke-v2.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google-cloud/storage": "^7.19.0",
|
||||
"express": "^4.21.2",
|
||||
"firebase-admin": "^13.0.2",
|
||||
"pg": "^8.16.3",
|
||||
|
||||
14
backend/command-api/scripts/dispatch-notifications.mjs
Normal file
14
backend/command-api/scripts/dispatch-notifications.mjs
Normal file
@@ -0,0 +1,14 @@
|
||||
import { dispatchPendingNotifications } from '../src/services/notification-dispatcher.js';
|
||||
import { closePool } from '../src/services/db.js';
|
||||
|
||||
try {
|
||||
const summary = await dispatchPendingNotifications();
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(JSON.stringify({ ok: true, summary }, null, 2));
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(JSON.stringify({ ok: false, error: error?.message || String(error) }, null, 2));
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await closePool();
|
||||
}
|
||||
348
backend/command-api/scripts/live-smoke-v2.mjs
Normal file
348
backend/command-api/scripts/live-smoke-v2.mjs
Normal file
@@ -0,0 +1,348 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { V2DemoFixture as fixture } from './v2-demo-fixture.mjs';
|
||||
|
||||
const firebaseApiKey = process.env.FIREBASE_API_KEY || 'AIzaSyBqRtZPMGU-Sz5x5UnRrunKu5NSWYyPRn8';
|
||||
const demoEmail = process.env.V2_SMOKE_EMAIL || fixture.users.businessOwner.email;
|
||||
const demoPassword = process.env.V2_SMOKE_PASSWORD || 'Demo2026!';
|
||||
const commandBaseUrl = process.env.COMMAND_API_BASE_URL || 'https://krow-command-api-v2-e3g6witsvq-uc.a.run.app';
|
||||
const queryBaseUrl = process.env.QUERY_API_BASE_URL || 'https://krow-query-api-v2-e3g6witsvq-uc.a.run.app';
|
||||
|
||||
async function signInWithPassword() {
|
||||
const response = await fetch(
|
||||
`https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=${firebaseApiKey}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: demoEmail,
|
||||
password: demoPassword,
|
||||
returnSecureToken: true,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(`Firebase sign-in failed: ${JSON.stringify(payload)}`);
|
||||
}
|
||||
|
||||
return {
|
||||
idToken: payload.idToken,
|
||||
localId: payload.localId,
|
||||
};
|
||||
}
|
||||
|
||||
async function apiCall(baseUrl, path, {
|
||||
method = 'GET',
|
||||
token,
|
||||
idempotencyKey,
|
||||
body,
|
||||
expectedStatus = 200,
|
||||
} = {}) {
|
||||
const headers = {};
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
if (idempotencyKey) {
|
||||
headers['Idempotency-Key'] = idempotencyKey;
|
||||
}
|
||||
if (body !== undefined) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
const payload = text ? JSON.parse(text) : {};
|
||||
|
||||
if (response.status !== expectedStatus) {
|
||||
throw new Error(`${method} ${path} expected ${expectedStatus}, got ${response.status}: ${text}`);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
function uniqueKey(prefix) {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function logStep(step, payload) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[live-smoke-v2] ${step}: ${JSON.stringify(payload)}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const auth = await signInWithPassword();
|
||||
assert.equal(auth.localId, fixture.users.businessOwner.id);
|
||||
logStep('auth.ok', { uid: auth.localId, email: demoEmail });
|
||||
|
||||
const listOrders = await apiCall(
|
||||
queryBaseUrl,
|
||||
`/query/tenants/${fixture.tenant.id}/orders`,
|
||||
{ token: auth.idToken }
|
||||
);
|
||||
assert.ok(Array.isArray(listOrders.items));
|
||||
assert.ok(listOrders.items.some((item) => item.id === fixture.orders.open.id));
|
||||
logStep('orders.list.ok', { count: listOrders.items.length });
|
||||
|
||||
const openOrderDetail = await apiCall(
|
||||
queryBaseUrl,
|
||||
`/query/tenants/${fixture.tenant.id}/orders/${fixture.orders.open.id}`,
|
||||
{ token: auth.idToken }
|
||||
);
|
||||
assert.equal(openOrderDetail.id, fixture.orders.open.id);
|
||||
assert.equal(openOrderDetail.shifts[0].id, fixture.shifts.open.id);
|
||||
logStep('orders.detail.ok', { orderId: openOrderDetail.id, shiftCount: openOrderDetail.shifts.length });
|
||||
|
||||
const favoriteResult = await apiCall(
|
||||
commandBaseUrl,
|
||||
`/commands/businesses/${fixture.business.id}/favorite-staff`,
|
||||
{
|
||||
method: 'POST',
|
||||
token: auth.idToken,
|
||||
idempotencyKey: uniqueKey('favorite'),
|
||||
body: {
|
||||
tenantId: fixture.tenant.id,
|
||||
staffId: fixture.staff.ana.id,
|
||||
},
|
||||
}
|
||||
);
|
||||
assert.equal(favoriteResult.staffId, fixture.staff.ana.id);
|
||||
logStep('favorites.add.ok', favoriteResult);
|
||||
|
||||
const favoriteList = await apiCall(
|
||||
queryBaseUrl,
|
||||
`/query/tenants/${fixture.tenant.id}/businesses/${fixture.business.id}/favorite-staff`,
|
||||
{ token: auth.idToken }
|
||||
);
|
||||
assert.ok(favoriteList.items.some((item) => item.staffId === fixture.staff.ana.id));
|
||||
logStep('favorites.list.ok', { count: favoriteList.items.length });
|
||||
|
||||
const reviewResult = await apiCall(
|
||||
commandBaseUrl,
|
||||
`/commands/assignments/${fixture.assignments.completedAna.id}/reviews`,
|
||||
{
|
||||
method: 'POST',
|
||||
token: auth.idToken,
|
||||
idempotencyKey: uniqueKey('review'),
|
||||
body: {
|
||||
tenantId: fixture.tenant.id,
|
||||
businessId: fixture.business.id,
|
||||
staffId: fixture.staff.ana.id,
|
||||
rating: 5,
|
||||
reviewText: 'Live smoke review',
|
||||
tags: ['smoke', 'reliable'],
|
||||
},
|
||||
}
|
||||
);
|
||||
assert.equal(reviewResult.staffId, fixture.staff.ana.id);
|
||||
logStep('reviews.create.ok', reviewResult);
|
||||
|
||||
const reviewSummary = await apiCall(
|
||||
queryBaseUrl,
|
||||
`/query/tenants/${fixture.tenant.id}/staff/${fixture.staff.ana.id}/review-summary`,
|
||||
{ token: auth.idToken }
|
||||
);
|
||||
assert.equal(reviewSummary.staffId, fixture.staff.ana.id);
|
||||
assert.ok(reviewSummary.ratingCount >= 1);
|
||||
logStep('reviews.summary.ok', { ratingCount: reviewSummary.ratingCount, averageRating: reviewSummary.averageRating });
|
||||
|
||||
const assigned = await apiCall(
|
||||
commandBaseUrl,
|
||||
`/commands/shifts/${fixture.shifts.open.id}/assign-staff`,
|
||||
{
|
||||
method: 'POST',
|
||||
token: auth.idToken,
|
||||
idempotencyKey: uniqueKey('assign'),
|
||||
body: {
|
||||
tenantId: fixture.tenant.id,
|
||||
shiftRoleId: fixture.shiftRoles.openBarista.id,
|
||||
workforceId: fixture.workforce.ana.id,
|
||||
applicationId: fixture.applications.openAna.id,
|
||||
},
|
||||
}
|
||||
);
|
||||
assert.equal(assigned.shiftId, fixture.shifts.open.id);
|
||||
logStep('assign.ok', assigned);
|
||||
|
||||
const accepted = await apiCall(
|
||||
commandBaseUrl,
|
||||
`/commands/shifts/${fixture.shifts.open.id}/accept`,
|
||||
{
|
||||
method: 'POST',
|
||||
token: auth.idToken,
|
||||
idempotencyKey: uniqueKey('accept'),
|
||||
body: {
|
||||
shiftRoleId: fixture.shiftRoles.openBarista.id,
|
||||
workforceId: fixture.workforce.ana.id,
|
||||
},
|
||||
}
|
||||
);
|
||||
assert.ok(['ASSIGNED', 'ACCEPTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED'].includes(accepted.status));
|
||||
const liveAssignmentId = accepted.assignmentId || assigned.assignmentId;
|
||||
logStep('accept.ok', accepted);
|
||||
|
||||
const clockIn = await apiCall(
|
||||
commandBaseUrl,
|
||||
'/commands/attendance/clock-in',
|
||||
{
|
||||
method: 'POST',
|
||||
token: auth.idToken,
|
||||
idempotencyKey: uniqueKey('clockin'),
|
||||
body: {
|
||||
assignmentId: liveAssignmentId,
|
||||
sourceType: 'NFC',
|
||||
sourceReference: 'smoke',
|
||||
nfcTagUid: fixture.clockPoint.nfcTagUid,
|
||||
deviceId: 'smoke-device',
|
||||
latitude: fixture.clockPoint.latitude,
|
||||
longitude: fixture.clockPoint.longitude,
|
||||
accuracyMeters: 5,
|
||||
},
|
||||
}
|
||||
);
|
||||
assert.equal(clockIn.assignmentId, liveAssignmentId);
|
||||
logStep('attendance.clockin.ok', clockIn);
|
||||
|
||||
const clockOut = await apiCall(
|
||||
commandBaseUrl,
|
||||
'/commands/attendance/clock-out',
|
||||
{
|
||||
method: 'POST',
|
||||
token: auth.idToken,
|
||||
idempotencyKey: uniqueKey('clockout'),
|
||||
body: {
|
||||
assignmentId: liveAssignmentId,
|
||||
sourceType: 'NFC',
|
||||
sourceReference: 'smoke',
|
||||
nfcTagUid: fixture.clockPoint.nfcTagUid,
|
||||
deviceId: 'smoke-device',
|
||||
latitude: fixture.clockPoint.latitude,
|
||||
longitude: fixture.clockPoint.longitude,
|
||||
accuracyMeters: 5,
|
||||
},
|
||||
}
|
||||
);
|
||||
assert.equal(clockOut.assignmentId, liveAssignmentId);
|
||||
logStep('attendance.clockout.ok', clockOut);
|
||||
|
||||
const attendance = await apiCall(
|
||||
queryBaseUrl,
|
||||
`/query/tenants/${fixture.tenant.id}/assignments/${liveAssignmentId}/attendance`,
|
||||
{ token: auth.idToken }
|
||||
);
|
||||
assert.ok(Array.isArray(attendance.events));
|
||||
assert.ok(attendance.events.length >= 2);
|
||||
logStep('attendance.query.ok', { eventCount: attendance.events.length, sessionStatus: attendance.sessionStatus });
|
||||
|
||||
const orderNumber = `ORD-V2-SMOKE-${Date.now()}`;
|
||||
const createdOrder = await apiCall(
|
||||
commandBaseUrl,
|
||||
'/commands/orders/create',
|
||||
{
|
||||
method: 'POST',
|
||||
token: auth.idToken,
|
||||
idempotencyKey: uniqueKey('order-create'),
|
||||
body: {
|
||||
tenantId: fixture.tenant.id,
|
||||
businessId: fixture.business.id,
|
||||
vendorId: fixture.vendor.id,
|
||||
orderNumber,
|
||||
title: 'Smoke created order',
|
||||
serviceType: 'EVENT',
|
||||
shifts: [
|
||||
{
|
||||
shiftCode: `SHIFT-${Date.now()}`,
|
||||
title: 'Smoke shift',
|
||||
startsAt: new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(),
|
||||
endsAt: new Date(Date.now() + 6 * 60 * 60 * 1000).toISOString(),
|
||||
requiredWorkers: 1,
|
||||
clockPointId: fixture.clockPoint.id,
|
||||
roles: [
|
||||
{
|
||||
roleCode: fixture.roles.barista.code,
|
||||
roleName: fixture.roles.barista.name,
|
||||
workersNeeded: 1,
|
||||
payRateCents: 2200,
|
||||
billRateCents: 3500,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
);
|
||||
assert.equal(createdOrder.orderNumber, orderNumber);
|
||||
logStep('orders.create.ok', createdOrder);
|
||||
|
||||
const updatedOrder = await apiCall(
|
||||
commandBaseUrl,
|
||||
`/commands/orders/${createdOrder.orderId}/update`,
|
||||
{
|
||||
method: 'POST',
|
||||
token: auth.idToken,
|
||||
idempotencyKey: uniqueKey('order-update'),
|
||||
body: {
|
||||
tenantId: fixture.tenant.id,
|
||||
title: 'Smoke updated order',
|
||||
notes: 'updated during live smoke',
|
||||
},
|
||||
}
|
||||
);
|
||||
assert.equal(updatedOrder.orderId, createdOrder.orderId);
|
||||
logStep('orders.update.ok', updatedOrder);
|
||||
|
||||
const changedShift = await apiCall(
|
||||
commandBaseUrl,
|
||||
`/commands/shifts/${createdOrder.shiftIds[0]}/change-status`,
|
||||
{
|
||||
method: 'POST',
|
||||
token: auth.idToken,
|
||||
idempotencyKey: uniqueKey('shift-status'),
|
||||
body: {
|
||||
tenantId: fixture.tenant.id,
|
||||
status: 'PENDING_CONFIRMATION',
|
||||
reason: 'live smoke transition',
|
||||
},
|
||||
}
|
||||
);
|
||||
assert.equal(changedShift.status, 'PENDING_CONFIRMATION');
|
||||
logStep('shift.status.ok', changedShift);
|
||||
|
||||
const cancelledOrder = await apiCall(
|
||||
commandBaseUrl,
|
||||
`/commands/orders/${createdOrder.orderId}/cancel`,
|
||||
{
|
||||
method: 'POST',
|
||||
token: auth.idToken,
|
||||
idempotencyKey: uniqueKey('order-cancel'),
|
||||
body: {
|
||||
tenantId: fixture.tenant.id,
|
||||
reason: 'live smoke cleanup',
|
||||
},
|
||||
}
|
||||
);
|
||||
assert.equal(cancelledOrder.status, 'CANCELLED');
|
||||
logStep('orders.cancel.ok', cancelledOrder);
|
||||
|
||||
const cancelledOrderDetail = await apiCall(
|
||||
queryBaseUrl,
|
||||
`/query/tenants/${fixture.tenant.id}/orders/${createdOrder.orderId}`,
|
||||
{ token: auth.idToken }
|
||||
);
|
||||
assert.equal(cancelledOrderDetail.status, 'CANCELLED');
|
||||
logStep('orders.cancel.verify.ok', { orderId: cancelledOrderDetail.id, status: cancelledOrderDetail.status });
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('LIVE_SMOKE_V2_OK');
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -3,11 +3,11 @@ import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Pool } from 'pg';
|
||||
|
||||
const databaseUrl = process.env.IDEMPOTENCY_DATABASE_URL;
|
||||
const databaseUrl = process.env.IDEMPOTENCY_DATABASE_URL || process.env.DATABASE_URL;
|
||||
|
||||
if (!databaseUrl) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('IDEMPOTENCY_DATABASE_URL is required');
|
||||
console.error('IDEMPOTENCY_DATABASE_URL or DATABASE_URL is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
69
backend/command-api/scripts/migrate-v2-schema.mjs
Normal file
69
backend/command-api/scripts/migrate-v2-schema.mjs
Normal file
@@ -0,0 +1,69 @@
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Pool } from 'pg';
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
|
||||
if (!databaseUrl) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('DATABASE_URL is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const scriptDir = resolve(fileURLToPath(new URL('.', import.meta.url)));
|
||||
const migrationsDir = resolve(scriptDir, '../sql/v2');
|
||||
|
||||
const migrationFiles = readdirSync(migrationsDir)
|
||||
.filter((file) => file.endsWith('.sql'))
|
||||
.sort();
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: databaseUrl,
|
||||
max: Number.parseInt(process.env.DB_POOL_MAX || '5', 10),
|
||||
});
|
||||
|
||||
async function ensureMigrationTable(client) {
|
||||
await client.query(`
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version TEXT PRIMARY KEY,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
try {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await ensureMigrationTable(client);
|
||||
|
||||
for (const file of migrationFiles) {
|
||||
const alreadyApplied = await client.query(
|
||||
'SELECT 1 FROM schema_migrations WHERE version = $1',
|
||||
[file]
|
||||
);
|
||||
if (alreadyApplied.rowCount > 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sql = readFileSync(resolve(migrationsDir, file), 'utf8');
|
||||
await client.query(sql);
|
||||
await client.query(
|
||||
'INSERT INTO schema_migrations (version) VALUES ($1)',
|
||||
[file]
|
||||
);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Applied migration ${file}`);
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
965
backend/command-api/scripts/seed-v2-demo-data.mjs
Normal file
965
backend/command-api/scripts/seed-v2-demo-data.mjs
Normal file
@@ -0,0 +1,965 @@
|
||||
import { Pool } from 'pg';
|
||||
import { resolveDatabasePoolConfig } from '../src/services/db.js';
|
||||
import { V2DemoFixture as fixture } from './v2-demo-fixture.mjs';
|
||||
|
||||
const poolConfig = resolveDatabasePoolConfig();
|
||||
|
||||
if (!poolConfig) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Database connection settings are required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const pool = new Pool(poolConfig);
|
||||
|
||||
function hoursFromNow(hours) {
|
||||
return new Date(Date.now() + (hours * 60 * 60 * 1000)).toISOString();
|
||||
}
|
||||
|
||||
async function upsertUser(client, user) {
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO users (id, email, display_name, status, metadata)
|
||||
VALUES ($1, $2, $3, 'ACTIVE', '{}'::jsonb)
|
||||
ON CONFLICT (id) DO UPDATE
|
||||
SET email = EXCLUDED.email,
|
||||
display_name = EXCLUDED.display_name,
|
||||
status = 'ACTIVE',
|
||||
updated_at = NOW()
|
||||
`,
|
||||
[user.id, user.email || null, user.displayName || null]
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
await client.query('DELETE FROM tenants WHERE id = $1', [fixture.tenant.id]);
|
||||
|
||||
const openStartsAt = hoursFromNow(4);
|
||||
const openEndsAt = hoursFromNow(12);
|
||||
const completedStartsAt = hoursFromNow(-28);
|
||||
const completedEndsAt = hoursFromNow(-20);
|
||||
const checkedInAt = hoursFromNow(-27.5);
|
||||
const checkedOutAt = hoursFromNow(-20.25);
|
||||
const assignedStartsAt = hoursFromNow(0.1);
|
||||
const assignedEndsAt = hoursFromNow(8.1);
|
||||
const availableStartsAt = hoursFromNow(30);
|
||||
const availableEndsAt = hoursFromNow(38);
|
||||
const cancelledStartsAt = hoursFromNow(20);
|
||||
const cancelledEndsAt = hoursFromNow(28);
|
||||
const noShowStartsAt = hoursFromNow(-18);
|
||||
const noShowEndsAt = hoursFromNow(-10);
|
||||
const invoiceDueAt = hoursFromNow(72);
|
||||
|
||||
await upsertUser(client, fixture.users.businessOwner);
|
||||
await upsertUser(client, fixture.users.operationsManager);
|
||||
await upsertUser(client, fixture.users.vendorManager);
|
||||
await upsertUser(client, fixture.users.staffAna);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO tenants (id, slug, name, status, metadata)
|
||||
VALUES ($1, $2, $3, 'ACTIVE', $4::jsonb)
|
||||
`,
|
||||
[fixture.tenant.id, fixture.tenant.slug, fixture.tenant.name, JSON.stringify({ seededBy: 'seed-v2-demo-data' })]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO tenant_memberships (tenant_id, user_id, membership_status, base_role, metadata)
|
||||
VALUES
|
||||
($1, $2, 'ACTIVE', 'admin', '{"persona":"business_owner"}'::jsonb),
|
||||
($1, $3, 'ACTIVE', 'manager', '{"persona":"ops_manager"}'::jsonb),
|
||||
($1, $4, 'ACTIVE', 'manager', '{"persona":"vendor_manager"}'::jsonb),
|
||||
($1, $5, 'ACTIVE', 'member', '{"persona":"staff"}'::jsonb)
|
||||
`,
|
||||
[
|
||||
fixture.tenant.id,
|
||||
fixture.users.businessOwner.id,
|
||||
fixture.users.operationsManager.id,
|
||||
fixture.users.vendorManager.id,
|
||||
fixture.users.staffAna.id,
|
||||
]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO businesses (
|
||||
id, tenant_id, slug, business_name, status, contact_name, contact_email, contact_phone, metadata
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, 'ACTIVE', $5, $6, $7, $8::jsonb)
|
||||
`,
|
||||
[
|
||||
fixture.business.id,
|
||||
fixture.tenant.id,
|
||||
fixture.business.slug,
|
||||
fixture.business.name,
|
||||
'Legendary Client Manager',
|
||||
fixture.users.businessOwner.email,
|
||||
'+15550001001',
|
||||
JSON.stringify({ segment: 'buyer', seeded: true }),
|
||||
]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO business_memberships (
|
||||
tenant_id, business_id, user_id, membership_status, business_role, metadata
|
||||
)
|
||||
VALUES
|
||||
($1, $2, $3, 'ACTIVE', 'owner', '{"persona":"client_owner"}'::jsonb),
|
||||
($1, $2, $4, 'ACTIVE', 'manager', '{"persona":"client_ops"}'::jsonb)
|
||||
`,
|
||||
[fixture.tenant.id, fixture.business.id, fixture.users.businessOwner.id, fixture.users.operationsManager.id]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO vendors (
|
||||
id, tenant_id, slug, company_name, status, contact_name, contact_email, contact_phone, metadata
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, 'ACTIVE', $5, $6, $7, $8::jsonb)
|
||||
`,
|
||||
[
|
||||
fixture.vendor.id,
|
||||
fixture.tenant.id,
|
||||
fixture.vendor.slug,
|
||||
fixture.vendor.name,
|
||||
'Vendor Manager',
|
||||
fixture.users.vendorManager.email,
|
||||
'+15550001002',
|
||||
JSON.stringify({ kind: 'internal_pool', seeded: true }),
|
||||
]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO vendor_memberships (
|
||||
tenant_id, vendor_id, user_id, membership_status, vendor_role, metadata
|
||||
)
|
||||
VALUES ($1, $2, $3, 'ACTIVE', 'owner', '{"persona":"vendor_owner"}'::jsonb)
|
||||
`,
|
||||
[fixture.tenant.id, fixture.vendor.id, fixture.users.vendorManager.id]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO cost_centers (id, tenant_id, business_id, code, name, status, metadata)
|
||||
VALUES ($1, $2, $3, 'CAFE_OPS', $4, 'ACTIVE', '{"seeded":true}'::jsonb)
|
||||
`,
|
||||
[fixture.costCenters.cafeOps.id, fixture.tenant.id, fixture.business.id, fixture.costCenters.cafeOps.name]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO roles_catalog (id, tenant_id, code, name, status, metadata)
|
||||
VALUES
|
||||
($1, $3, $4, $5, 'ACTIVE', '{}'::jsonb),
|
||||
($2, $3, $6, $7, 'ACTIVE', '{}'::jsonb)
|
||||
`,
|
||||
[
|
||||
fixture.roles.barista.id,
|
||||
fixture.roles.captain.id,
|
||||
fixture.tenant.id,
|
||||
fixture.roles.barista.code,
|
||||
fixture.roles.barista.name,
|
||||
fixture.roles.captain.code,
|
||||
fixture.roles.captain.name,
|
||||
]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO staffs (
|
||||
id, tenant_id, user_id, full_name, email, phone, status, primary_role, onboarding_status,
|
||||
average_rating, rating_count, metadata
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'ACTIVE', $7, 'COMPLETED', 4.50, 1, $8::jsonb)
|
||||
`,
|
||||
[
|
||||
fixture.staff.ana.id,
|
||||
fixture.tenant.id,
|
||||
fixture.users.staffAna.id,
|
||||
fixture.staff.ana.fullName,
|
||||
fixture.staff.ana.email,
|
||||
fixture.staff.ana.phone,
|
||||
fixture.staff.ana.primaryRole,
|
||||
JSON.stringify({
|
||||
favoriteCandidate: true,
|
||||
seeded: true,
|
||||
firstName: 'Ana',
|
||||
lastName: 'Barista',
|
||||
bio: 'Experienced barista and event staffing professional.',
|
||||
preferredLocations: [
|
||||
{
|
||||
city: 'Mountain View',
|
||||
latitude: fixture.clockPoint.latitude,
|
||||
longitude: fixture.clockPoint.longitude,
|
||||
},
|
||||
],
|
||||
maxDistanceMiles: 20,
|
||||
industries: ['CATERING', 'CAFE'],
|
||||
skills: ['BARISTA', 'CUSTOMER_SERVICE'],
|
||||
emergencyContact: {
|
||||
name: 'Maria Barista',
|
||||
phone: '+15550007777',
|
||||
},
|
||||
}),
|
||||
]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO staff_roles (staff_id, role_id, is_primary)
|
||||
VALUES ($1, $2, TRUE)
|
||||
`,
|
||||
[fixture.staff.ana.id, fixture.roles.barista.id]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO workforce (id, tenant_id, vendor_id, staff_id, workforce_number, employment_type, status, metadata)
|
||||
VALUES ($1, $2, $3, $4, $5, 'TEMP', 'ACTIVE', $6::jsonb)
|
||||
`,
|
||||
[
|
||||
fixture.workforce.ana.id,
|
||||
fixture.tenant.id,
|
||||
fixture.vendor.id,
|
||||
fixture.staff.ana.id,
|
||||
fixture.workforce.ana.workforceNumber,
|
||||
JSON.stringify({ source: 'seed-v2-demo' }),
|
||||
]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO staff_availability (
|
||||
id, tenant_id, staff_id, day_of_week, availability_status, time_slots, metadata
|
||||
)
|
||||
VALUES
|
||||
($1, $3, $4, 1, 'PARTIAL', '[{"start":"08:00","end":"18:00"}]'::jsonb, '{"seeded":true}'::jsonb),
|
||||
($2, $3, $4, 5, 'PARTIAL', '[{"start":"09:00","end":"17:00"}]'::jsonb, '{"seeded":true}'::jsonb)
|
||||
`,
|
||||
[fixture.availability.monday.id, fixture.availability.friday.id, fixture.tenant.id, fixture.staff.ana.id]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO staff_benefits (
|
||||
id, tenant_id, staff_id, benefit_type, title, status, tracked_hours, target_hours, metadata
|
||||
)
|
||||
VALUES ($1, $2, $3, 'COMMUTER', $4, 'ACTIVE', 32, 40, '{"description":"Commuter stipend unlocked after 40 hours"}'::jsonb)
|
||||
`,
|
||||
[fixture.benefits.commuter.id, fixture.tenant.id, fixture.staff.ana.id, fixture.benefits.commuter.title]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO emergency_contacts (
|
||||
id, tenant_id, staff_id, full_name, phone, relationship_type, is_primary, metadata
|
||||
)
|
||||
VALUES ($1, $2, $3, 'Maria Barista', '+15550007777', 'SIBLING', TRUE, '{"seeded":true}'::jsonb)
|
||||
`,
|
||||
[fixture.emergencyContacts.primary.id, fixture.tenant.id, fixture.staff.ana.id]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO clock_points (
|
||||
id, tenant_id, business_id, cost_center_id, label, address, latitude, longitude,
|
||||
geofence_radius_meters, nfc_tag_uid, default_clock_in_mode, allow_clock_in_override, status, metadata
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 'ACTIVE', $13::jsonb)
|
||||
`,
|
||||
[
|
||||
fixture.clockPoint.id,
|
||||
fixture.tenant.id,
|
||||
fixture.business.id,
|
||||
fixture.costCenters.cafeOps.id,
|
||||
fixture.clockPoint.label,
|
||||
fixture.clockPoint.address,
|
||||
fixture.clockPoint.latitude,
|
||||
fixture.clockPoint.longitude,
|
||||
fixture.clockPoint.geofenceRadiusMeters,
|
||||
fixture.clockPoint.nfcTagUid,
|
||||
fixture.clockPoint.defaultClockInMode,
|
||||
fixture.clockPoint.allowClockInOverride,
|
||||
JSON.stringify({ city: 'Mountain View', state: 'CA', zipCode: '94043', seeded: true }),
|
||||
]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO hub_managers (id, tenant_id, hub_id, business_membership_id)
|
||||
SELECT $1, $2, $3, bm.id
|
||||
FROM business_memberships bm
|
||||
WHERE bm.business_id = $4
|
||||
AND bm.user_id = $5
|
||||
`,
|
||||
[
|
||||
fixture.hubManagers.opsLead.id,
|
||||
fixture.tenant.id,
|
||||
fixture.clockPoint.id,
|
||||
fixture.business.id,
|
||||
fixture.users.operationsManager.id,
|
||||
]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO orders (
|
||||
id, tenant_id, business_id, vendor_id, order_number, title, description, status, service_type,
|
||||
starts_at, ends_at, location_name, location_address, latitude, longitude, notes, created_by_user_id, metadata
|
||||
)
|
||||
VALUES
|
||||
($1, $3, $4, $5, $6, $7, 'Open order for live v2 commands', 'OPEN', 'EVENT', $8, $9, 'Google Cafe', $10, $11, $12, 'Use this order for live smoke and frontend reads', $13, '{"slice":"open","orderType":"ONE_TIME"}'::jsonb),
|
||||
($2, $3, $4, $5, $14, $15, 'Completed order for favorites, reviews, invoices, and attendance history', 'COMPLETED', 'CATERING', $16, $17, 'Google Catering', $10, $11, $12, 'Completed historical example', $13, '{"slice":"completed","orderType":"ONE_TIME"}'::jsonb)
|
||||
`,
|
||||
[
|
||||
fixture.orders.open.id,
|
||||
fixture.orders.completed.id,
|
||||
fixture.tenant.id,
|
||||
fixture.business.id,
|
||||
fixture.vendor.id,
|
||||
fixture.orders.open.number,
|
||||
fixture.orders.open.title,
|
||||
openStartsAt,
|
||||
openEndsAt,
|
||||
fixture.clockPoint.address,
|
||||
fixture.clockPoint.latitude,
|
||||
fixture.clockPoint.longitude,
|
||||
fixture.users.businessOwner.id,
|
||||
fixture.orders.completed.number,
|
||||
fixture.orders.completed.title,
|
||||
completedStartsAt,
|
||||
completedEndsAt,
|
||||
]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO orders (
|
||||
id, tenant_id, business_id, vendor_id, order_number, title, description, status, service_type,
|
||||
starts_at, ends_at, location_name, location_address, latitude, longitude, notes, created_by_user_id, metadata
|
||||
)
|
||||
VALUES (
|
||||
$1, $2, $3, $4, $5, $6, 'Active order used to populate assigned, available, cancelled, and no-show shift states',
|
||||
'ACTIVE', 'RESTAURANT', $7, $8, 'Google Cafe', $9, $10, $11, 'Mixed state scenario order', $12,
|
||||
'{"slice":"active","orderType":"ONE_TIME"}'::jsonb
|
||||
)
|
||||
`,
|
||||
[
|
||||
fixture.orders.active.id,
|
||||
fixture.tenant.id,
|
||||
fixture.business.id,
|
||||
fixture.vendor.id,
|
||||
fixture.orders.active.number,
|
||||
fixture.orders.active.title,
|
||||
assignedStartsAt,
|
||||
availableEndsAt,
|
||||
fixture.clockPoint.address,
|
||||
fixture.clockPoint.latitude,
|
||||
fixture.clockPoint.longitude,
|
||||
fixture.users.operationsManager.id,
|
||||
]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO shifts (
|
||||
id, tenant_id, order_id, business_id, vendor_id, clock_point_id, shift_code, title, status, starts_at, ends_at, timezone,
|
||||
location_name, location_address, latitude, longitude, geofence_radius_meters, clock_in_mode, allow_clock_in_override,
|
||||
required_workers, assigned_workers, notes, metadata
|
||||
)
|
||||
VALUES
|
||||
($1, $3, $5, $7, $9, $11, $13, $15, 'OPEN', $17, $18, 'America/Los_Angeles', 'Google Cafe', $19, $21, $22, $23, NULL, NULL, 1, 0, 'Open staffing need', '{"slice":"open"}'::jsonb),
|
||||
($2, $4, $6, $8, $10, $12, $14, $16, 'COMPLETED', $20, $24, 'America/Los_Angeles', 'Google Catering', $19, $21, $22, $23, NULL, NULL, 1, 1, 'Completed staffed shift', '{"slice":"completed"}'::jsonb)
|
||||
`,
|
||||
[
|
||||
fixture.shifts.open.id,
|
||||
fixture.shifts.completed.id,
|
||||
fixture.tenant.id,
|
||||
fixture.tenant.id,
|
||||
fixture.orders.open.id,
|
||||
fixture.orders.completed.id,
|
||||
fixture.business.id,
|
||||
fixture.business.id,
|
||||
fixture.vendor.id,
|
||||
fixture.vendor.id,
|
||||
fixture.clockPoint.id,
|
||||
fixture.clockPoint.id,
|
||||
fixture.shifts.open.code,
|
||||
fixture.shifts.completed.code,
|
||||
fixture.shifts.open.title,
|
||||
fixture.shifts.completed.title,
|
||||
openStartsAt,
|
||||
openEndsAt,
|
||||
fixture.clockPoint.address,
|
||||
completedStartsAt,
|
||||
fixture.clockPoint.latitude,
|
||||
fixture.clockPoint.longitude,
|
||||
fixture.clockPoint.geofenceRadiusMeters,
|
||||
completedEndsAt,
|
||||
]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO shifts (
|
||||
id, tenant_id, order_id, business_id, vendor_id, clock_point_id, shift_code, title, status, starts_at, ends_at, timezone,
|
||||
location_name, location_address, latitude, longitude, geofence_radius_meters, clock_in_mode, allow_clock_in_override,
|
||||
required_workers, assigned_workers, notes, metadata
|
||||
)
|
||||
VALUES
|
||||
($1, $2, $3, $4, $5, $6, $7, $8, 'OPEN', $9, $10, 'America/Los_Angeles', 'Google Cafe', $11, $12, $13, $14, NULL, NULL, 1, 0, 'Available shift for staff marketplace', '{"slice":"available"}'::jsonb),
|
||||
($15, $2, $3, $4, $5, $6, $16, $17, 'ASSIGNED', $18, $19, 'America/Los_Angeles', 'Google Cafe', $11, $12, $13, $14, $30, $31, 1, 1, 'Assigned shift waiting for staff confirmation', '{"slice":"assigned"}'::jsonb),
|
||||
($20, $2, $3, $4, $5, $6, $21, $22, 'CANCELLED', $23, $24, 'America/Los_Angeles', 'Google Cafe', $11, $12, $13, $14, NULL, NULL, 1, 0, 'Cancelled shift history sample', '{"slice":"cancelled"}'::jsonb),
|
||||
($25, $2, $3, $4, $5, $6, $26, $27, 'COMPLETED', $28, $29, 'America/Los_Angeles', 'Google Cafe', $11, $12, $13, $14, 'GEO_REQUIRED', TRUE, 1, 0, 'No-show historical sample', '{"slice":"no_show"}'::jsonb)
|
||||
`,
|
||||
[
|
||||
fixture.shifts.available.id,
|
||||
fixture.tenant.id,
|
||||
fixture.orders.active.id,
|
||||
fixture.business.id,
|
||||
fixture.vendor.id,
|
||||
fixture.clockPoint.id,
|
||||
fixture.shifts.available.code,
|
||||
fixture.shifts.available.title,
|
||||
availableStartsAt,
|
||||
availableEndsAt,
|
||||
fixture.clockPoint.address,
|
||||
fixture.clockPoint.latitude,
|
||||
fixture.clockPoint.longitude,
|
||||
fixture.clockPoint.geofenceRadiusMeters,
|
||||
fixture.shifts.assigned.id,
|
||||
fixture.shifts.assigned.code,
|
||||
fixture.shifts.assigned.title,
|
||||
assignedStartsAt,
|
||||
assignedEndsAt,
|
||||
fixture.shifts.cancelled.id,
|
||||
fixture.shifts.cancelled.code,
|
||||
fixture.shifts.cancelled.title,
|
||||
cancelledStartsAt,
|
||||
cancelledEndsAt,
|
||||
fixture.shifts.noShow.id,
|
||||
fixture.shifts.noShow.code,
|
||||
fixture.shifts.noShow.title,
|
||||
noShowStartsAt,
|
||||
noShowEndsAt,
|
||||
fixture.shifts.assigned.clockInMode,
|
||||
fixture.shifts.assigned.allowClockInOverride,
|
||||
]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO shift_roles (
|
||||
id, shift_id, role_id, role_code, role_name, workers_needed, assigned_count, pay_rate_cents, bill_rate_cents, metadata
|
||||
)
|
||||
VALUES
|
||||
($1, $2, $3, $4, $5, 1, 0, 2200, 3500, '{"slice":"open"}'::jsonb),
|
||||
($6, $7, $3, $4, $5, 1, 1, 2200, 3500, '{"slice":"completed"}'::jsonb)
|
||||
`,
|
||||
[
|
||||
fixture.shiftRoles.openBarista.id,
|
||||
fixture.shifts.open.id,
|
||||
fixture.roles.barista.id,
|
||||
fixture.roles.barista.code,
|
||||
fixture.roles.barista.name,
|
||||
fixture.shiftRoles.completedBarista.id,
|
||||
fixture.shifts.completed.id,
|
||||
]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO shift_roles (
|
||||
id, shift_id, role_id, role_code, role_name, workers_needed, assigned_count, pay_rate_cents, bill_rate_cents, metadata
|
||||
)
|
||||
VALUES
|
||||
($1, $2, $7, $8, $9, 1, 0, 2200, 3500, '{"slice":"available"}'::jsonb),
|
||||
($3, $4, $7, $8, $9, 1, 1, 2300, 3600, '{"slice":"assigned"}'::jsonb),
|
||||
($5, $6, $7, $8, $9, 1, 0, 2200, 3500, '{"slice":"cancelled"}'::jsonb),
|
||||
($10, $11, $7, $8, $9, 1, 0, 2200, 3500, '{"slice":"no_show"}'::jsonb)
|
||||
`,
|
||||
[
|
||||
fixture.shiftRoles.availableBarista.id,
|
||||
fixture.shifts.available.id,
|
||||
fixture.shiftRoles.assignedBarista.id,
|
||||
fixture.shifts.assigned.id,
|
||||
fixture.shiftRoles.cancelledBarista.id,
|
||||
fixture.shifts.cancelled.id,
|
||||
fixture.roles.barista.id,
|
||||
fixture.roles.barista.code,
|
||||
fixture.roles.barista.name,
|
||||
fixture.shiftRoles.noShowBarista.id,
|
||||
fixture.shifts.noShow.id,
|
||||
]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO applications (
|
||||
id, tenant_id, shift_id, shift_role_id, staff_id, status, origin, applied_at, metadata
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, 'PENDING', 'STAFF', NOW(), '{"slice":"open"}'::jsonb)
|
||||
`,
|
||||
[
|
||||
fixture.applications.openAna.id,
|
||||
fixture.tenant.id,
|
||||
fixture.shifts.open.id,
|
||||
fixture.shiftRoles.openBarista.id,
|
||||
fixture.staff.ana.id,
|
||||
]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO assignments (
|
||||
id, tenant_id, business_id, vendor_id, shift_id, shift_role_id, workforce_id, staff_id, status,
|
||||
assigned_at, accepted_at, checked_in_at, checked_out_at, metadata
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'COMPLETED', $9, $10, $11, $12, '{"slice":"completed"}'::jsonb)
|
||||
`,
|
||||
[
|
||||
fixture.assignments.completedAna.id,
|
||||
fixture.tenant.id,
|
||||
fixture.business.id,
|
||||
fixture.vendor.id,
|
||||
fixture.shifts.completed.id,
|
||||
fixture.shiftRoles.completedBarista.id,
|
||||
fixture.workforce.ana.id,
|
||||
fixture.staff.ana.id,
|
||||
completedStartsAt,
|
||||
completedStartsAt,
|
||||
checkedInAt,
|
||||
checkedOutAt,
|
||||
]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO assignments (
|
||||
id, tenant_id, business_id, vendor_id, shift_id, shift_role_id, workforce_id, staff_id, status,
|
||||
assigned_at, accepted_at, checked_in_at, checked_out_at, metadata
|
||||
)
|
||||
VALUES
|
||||
($1, $2, $3, $4, $5, $6, $7, $8, 'ASSIGNED', NOW(), NULL, NULL, NULL, '{"slice":"assigned"}'::jsonb),
|
||||
($9, $2, $3, $4, $10, $11, $7, $8, 'CANCELLED', NOW(), NULL, NULL, NULL, '{"slice":"cancelled","cancellationReason":"Client cancelled"}'::jsonb),
|
||||
($12, $2, $3, $4, $13, $14, $7, $8, 'NO_SHOW', $15, NULL, NULL, NULL, '{"slice":"no_show"}'::jsonb)
|
||||
`,
|
||||
[
|
||||
fixture.assignments.assignedAna.id,
|
||||
fixture.tenant.id,
|
||||
fixture.business.id,
|
||||
fixture.vendor.id,
|
||||
fixture.shifts.assigned.id,
|
||||
fixture.shiftRoles.assignedBarista.id,
|
||||
fixture.workforce.ana.id,
|
||||
fixture.staff.ana.id,
|
||||
fixture.assignments.cancelledAna.id,
|
||||
fixture.shifts.cancelled.id,
|
||||
fixture.shiftRoles.cancelledBarista.id,
|
||||
fixture.assignments.noShowAna.id,
|
||||
fixture.shifts.noShow.id,
|
||||
fixture.shiftRoles.noShowBarista.id,
|
||||
noShowStartsAt,
|
||||
]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO attendance_events (
|
||||
tenant_id, assignment_id, shift_id, staff_id, clock_point_id, event_type, source_type, source_reference,
|
||||
nfc_tag_uid, device_id, latitude, longitude, accuracy_meters, distance_to_clock_point_meters, within_geofence,
|
||||
validation_status, validation_reason, captured_at, raw_payload
|
||||
)
|
||||
VALUES
|
||||
($1, $2, $3, $4, $5, 'CLOCK_IN', 'NFC', 'seed', $6, 'seed-device', $7, $8, 5, 0, TRUE, 'ACCEPTED', NULL, $9, '{"seeded":true}'::jsonb),
|
||||
($1, $2, $3, $4, $5, 'CLOCK_OUT', 'NFC', 'seed', $6, 'seed-device', $7, $8, 5, 0, TRUE, 'ACCEPTED', NULL, $10, '{"seeded":true}'::jsonb)
|
||||
`,
|
||||
[
|
||||
fixture.tenant.id,
|
||||
fixture.assignments.completedAna.id,
|
||||
fixture.shifts.completed.id,
|
||||
fixture.staff.ana.id,
|
||||
fixture.clockPoint.id,
|
||||
fixture.clockPoint.nfcTagUid,
|
||||
fixture.clockPoint.latitude,
|
||||
fixture.clockPoint.longitude,
|
||||
checkedInAt,
|
||||
checkedOutAt,
|
||||
]
|
||||
);
|
||||
|
||||
const attendanceEvents = await client.query(
|
||||
`
|
||||
SELECT id, event_type
|
||||
FROM attendance_events
|
||||
WHERE assignment_id = $1
|
||||
ORDER BY captured_at ASC
|
||||
`,
|
||||
[fixture.assignments.completedAna.id]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO attendance_sessions (
|
||||
id, tenant_id, assignment_id, staff_id, clock_in_event_id, clock_out_event_id, status,
|
||||
check_in_at, check_out_at, worked_minutes, metadata
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'CLOSED', $7, $8, 435, '{"seeded":true}'::jsonb)
|
||||
`,
|
||||
[
|
||||
'95f6017c-256c-4eb5-8033-eb942f018001',
|
||||
fixture.tenant.id,
|
||||
fixture.assignments.completedAna.id,
|
||||
fixture.staff.ana.id,
|
||||
attendanceEvents.rows.find((row) => row.event_type === 'CLOCK_IN')?.id,
|
||||
attendanceEvents.rows.find((row) => row.event_type === 'CLOCK_OUT')?.id,
|
||||
checkedInAt,
|
||||
checkedOutAt,
|
||||
]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO timesheets (
|
||||
id, tenant_id, assignment_id, staff_id, status, regular_minutes, overtime_minutes, break_minutes, gross_pay_cents, metadata
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, 'APPROVED', 420, 15, 30, 15950, '{"seeded":true}'::jsonb)
|
||||
`,
|
||||
[fixture.timesheets.completedAna.id, fixture.tenant.id, fixture.assignments.completedAna.id, fixture.staff.ana.id]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO documents (id, tenant_id, document_type, name, required_for_role_code, metadata)
|
||||
VALUES
|
||||
($1, $2, 'GOVERNMENT_ID', $3, $10, '{"seeded":true,"description":"State ID or passport","required":true}'::jsonb),
|
||||
($4, $2, 'CERTIFICATION', $5, $10, '{"seeded":true}'::jsonb),
|
||||
($6, $2, 'ATTIRE', $7, $10, '{"seeded":true,"description":"Upload a photo of your black shirt","required":true}'::jsonb),
|
||||
($8, $2, 'TAX_FORM', $9, $10, '{"seeded":true}'::jsonb),
|
||||
($11, $2, 'TAX_FORM', $12, $10, '{"seeded":true}'::jsonb)
|
||||
`,
|
||||
[
|
||||
fixture.documents.governmentId.id,
|
||||
fixture.tenant.id,
|
||||
fixture.documents.governmentId.name,
|
||||
fixture.documents.foodSafety.id,
|
||||
fixture.documents.foodSafety.name,
|
||||
fixture.documents.attireBlackShirt.id,
|
||||
fixture.documents.attireBlackShirt.name,
|
||||
fixture.documents.taxFormI9.id,
|
||||
fixture.documents.taxFormI9.name,
|
||||
fixture.roles.barista.code,
|
||||
fixture.documents.taxFormW4.id,
|
||||
fixture.documents.taxFormW4.name,
|
||||
]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO staff_documents (
|
||||
id, tenant_id, staff_id, document_id, file_uri, status, expires_at, metadata
|
||||
)
|
||||
VALUES
|
||||
($1, $2, $3, $4, $5, 'PENDING', $6, '{"seeded":true,"verificationStatus":"PENDING_REVIEW"}'::jsonb),
|
||||
($7, $2, $3, $8, $9, 'VERIFIED', $10, '{"seeded":true,"verificationStatus":"APPROVED"}'::jsonb),
|
||||
($11, $2, $3, $12, $13, 'VERIFIED', NULL, '{"seeded":true,"verificationStatus":"APPROVED"}'::jsonb),
|
||||
($14, $2, $3, $15, $16, 'VERIFIED', NULL, '{"seeded":true,"formStatus":"SUBMITTED","fields":{"ssnLast4":"1234","filingStatus":"single"}}'::jsonb),
|
||||
($17, $2, $3, $18, $19, 'PENDING', NULL, '{"seeded":true,"formStatus":"DRAFT","fields":{"section1Complete":true}}'::jsonb)
|
||||
`,
|
||||
[
|
||||
fixture.staffDocuments.governmentId.id,
|
||||
fixture.tenant.id,
|
||||
fixture.staff.ana.id,
|
||||
fixture.documents.governmentId.id,
|
||||
`gs://krow-workforce-dev-v2-private/uploads/${fixture.staff.ana.id}/government-id-front.jpg`,
|
||||
hoursFromNow(24 * 365),
|
||||
fixture.staffDocuments.foodSafety.id,
|
||||
fixture.documents.foodSafety.id,
|
||||
`gs://krow-workforce-dev-v2-private/uploads/${fixture.staff.ana.id}/food-handler-card.pdf`,
|
||||
hoursFromNow(24 * 180),
|
||||
fixture.staffDocuments.attireBlackShirt.id,
|
||||
fixture.documents.attireBlackShirt.id,
|
||||
`gs://krow-workforce-dev-v2-private/uploads/${fixture.staff.ana.id}/black-shirt.jpg`,
|
||||
fixture.staffDocuments.taxFormW4.id,
|
||||
fixture.documents.taxFormW4.id,
|
||||
`gs://krow-workforce-dev-v2-private/uploads/${fixture.staff.ana.id}/w4-form.pdf`,
|
||||
fixture.staffDocuments.taxFormI9.id,
|
||||
fixture.documents.taxFormI9.id,
|
||||
`gs://krow-workforce-dev-v2-private/uploads/${fixture.staff.ana.id}/i9-form.pdf`,
|
||||
]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO certificates (
|
||||
id, tenant_id, staff_id, certificate_type, certificate_number, issued_at, expires_at, status, file_uri, metadata
|
||||
)
|
||||
VALUES ($1, $2, $3, 'FOOD_SAFETY', 'FH-ANA-2026', $4, $5, 'VERIFIED', $6, $7::jsonb)
|
||||
`,
|
||||
[
|
||||
fixture.certificates.foodSafety.id,
|
||||
fixture.tenant.id,
|
||||
fixture.staff.ana.id,
|
||||
hoursFromNow(-24 * 30),
|
||||
hoursFromNow(24 * 180),
|
||||
`gs://krow-workforce-dev-v2-private/uploads/${fixture.staff.ana.id}/food-safety-certificate.pdf`,
|
||||
JSON.stringify({
|
||||
seeded: true,
|
||||
name: 'Food Safety Certificate',
|
||||
issuer: 'ServSafe',
|
||||
verificationStatus: 'APPROVED',
|
||||
}),
|
||||
]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO verification_jobs (
|
||||
tenant_id, staff_id, document_id, type, file_uri, status, idempotency_key,
|
||||
provider_name, provider_reference, confidence, reasons, extracted, review, metadata
|
||||
)
|
||||
VALUES (
|
||||
$1, $2, $3, 'certification', $4, 'APPROVED', 'seed-certification-job',
|
||||
'seed', 'seed-certification-provider', 0.980, '["Verified by seed"]'::jsonb,
|
||||
'{"certificateType":"FOOD_SAFETY"}'::jsonb, '{"decision":"APPROVED"}'::jsonb, '{"seeded":true}'::jsonb
|
||||
)
|
||||
`,
|
||||
[
|
||||
fixture.tenant.id,
|
||||
fixture.staff.ana.id,
|
||||
fixture.documents.foodSafety.id,
|
||||
`gs://krow-workforce-dev-v2-private/uploads/${fixture.staff.ana.id}/food-handler-card.pdf`,
|
||||
]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO accounts (
|
||||
id, tenant_id, owner_type, owner_business_id, owner_vendor_id, owner_staff_id,
|
||||
provider_name, provider_reference, last4, is_primary, metadata
|
||||
)
|
||||
VALUES
|
||||
($1, $3, 'BUSINESS', $4, NULL, NULL, 'stripe', 'ba_business_demo', '6789', TRUE, '{"seeded":true,"accountType":"CHECKING","routingNumberMasked":"*****0001"}'::jsonb),
|
||||
($2, $3, 'STAFF', NULL, NULL, $5, 'stripe', 'ba_staff_demo', '4321', TRUE, '{"seeded":true,"accountType":"CHECKING","routingNumberMasked":"*****0002"}'::jsonb)
|
||||
`,
|
||||
[
|
||||
fixture.accounts.businessPrimary.id,
|
||||
fixture.accounts.staffPrimary.id,
|
||||
fixture.tenant.id,
|
||||
fixture.business.id,
|
||||
fixture.staff.ana.id,
|
||||
]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO invoices (
|
||||
id, tenant_id, order_id, business_id, vendor_id, invoice_number, status, currency_code,
|
||||
subtotal_cents, tax_cents, total_cents, due_at, metadata
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'PENDING_REVIEW', 'USD', 15250, 700, 15950, $7, '{"seeded":true,"savingsCents":1250}'::jsonb)
|
||||
`,
|
||||
[
|
||||
fixture.invoices.completed.id,
|
||||
fixture.tenant.id,
|
||||
fixture.orders.completed.id,
|
||||
fixture.business.id,
|
||||
fixture.vendor.id,
|
||||
fixture.invoices.completed.number,
|
||||
invoiceDueAt,
|
||||
]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO recent_payments (
|
||||
id, tenant_id, invoice_id, assignment_id, staff_id, status, amount_cents, process_date, metadata
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, 'PENDING', 15950, NULL, '{"seeded":true}'::jsonb)
|
||||
`,
|
||||
[
|
||||
fixture.recentPayments.completed.id,
|
||||
fixture.tenant.id,
|
||||
fixture.invoices.completed.id,
|
||||
fixture.assignments.completedAna.id,
|
||||
fixture.staff.ana.id,
|
||||
]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO staff_favorites (id, tenant_id, business_id, staff_id, created_by_user_id, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, NOW())
|
||||
`,
|
||||
[
|
||||
fixture.favorites.ana.id,
|
||||
fixture.tenant.id,
|
||||
fixture.business.id,
|
||||
fixture.staff.ana.id,
|
||||
fixture.users.businessOwner.id,
|
||||
]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO staff_reviews (
|
||||
id, tenant_id, business_id, staff_id, assignment_id, reviewer_user_id, rating, review_text, tags, created_at, updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 5, 'Reliable, on time, and client friendly.', '["reliable","favorite"]'::jsonb, NOW(), NOW())
|
||||
`,
|
||||
[
|
||||
fixture.reviews.anaCompleted.id,
|
||||
fixture.tenant.id,
|
||||
fixture.business.id,
|
||||
fixture.staff.ana.id,
|
||||
fixture.assignments.completedAna.id,
|
||||
fixture.users.businessOwner.id,
|
||||
]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO domain_events (tenant_id, aggregate_type, aggregate_id, sequence, event_type, actor_user_id, payload)
|
||||
VALUES
|
||||
($1, 'order', $2, 1, 'ORDER_CREATED', $3, '{"seeded":true}'::jsonb),
|
||||
($1, 'assignment', $4, 1, 'STAFF_ASSIGNED', $3, '{"seeded":true}'::jsonb)
|
||||
`,
|
||||
[
|
||||
fixture.tenant.id,
|
||||
fixture.orders.completed.id,
|
||||
fixture.users.businessOwner.id,
|
||||
fixture.assignments.completedAna.id,
|
||||
]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO location_stream_batches (
|
||||
id, tenant_id, business_id, vendor_id, shift_id, assignment_id, staff_id, actor_user_id,
|
||||
source_type, device_id, object_uri, point_count, out_of_geofence_count, missing_coordinate_count,
|
||||
max_distance_to_clock_point_meters, started_at, ended_at, metadata
|
||||
)
|
||||
VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, 'GEO', 'seed-device',
|
||||
$9, 4, 2, 0, 910, $10, $11, '{"seeded":true,"source":"seed-v2-demo"}'::jsonb
|
||||
)
|
||||
`,
|
||||
[
|
||||
fixture.locationStreamBatches.noShowSample.id,
|
||||
fixture.tenant.id,
|
||||
fixture.business.id,
|
||||
fixture.vendor.id,
|
||||
fixture.shifts.noShow.id,
|
||||
fixture.assignments.noShowAna.id,
|
||||
fixture.staff.ana.id,
|
||||
fixture.users.staffAna.id,
|
||||
`gs://krow-workforce-dev-v2-private/location-streams/${fixture.tenant.id}/${fixture.staff.ana.id}/${fixture.assignments.noShowAna.id}/${fixture.locationStreamBatches.noShowSample.id}.json`,
|
||||
hoursFromNow(-18.25),
|
||||
hoursFromNow(-17.75),
|
||||
]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO geofence_incidents (
|
||||
id, tenant_id, business_id, vendor_id, shift_id, assignment_id, staff_id, actor_user_id, location_stream_batch_id,
|
||||
incident_type, severity, status, effective_clock_in_mode, source_type, device_id,
|
||||
latitude, longitude, accuracy_meters, distance_to_clock_point_meters, within_geofence,
|
||||
override_reason, message, occurred_at, metadata
|
||||
)
|
||||
VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9,
|
||||
'OUTSIDE_GEOFENCE', 'CRITICAL', 'OPEN', 'GEO_REQUIRED', 'GEO', 'seed-device',
|
||||
$10, $11, 12, 910, FALSE, NULL, 'Worker drifted outside hub geofence during active monitoring',
|
||||
$12, '{"seeded":true,"outOfGeofenceCount":2}'::jsonb
|
||||
)
|
||||
`,
|
||||
[
|
||||
fixture.geofenceIncidents.noShowOutsideGeofence.id,
|
||||
fixture.tenant.id,
|
||||
fixture.business.id,
|
||||
fixture.vendor.id,
|
||||
fixture.shifts.noShow.id,
|
||||
fixture.assignments.noShowAna.id,
|
||||
fixture.staff.ana.id,
|
||||
fixture.users.staffAna.id,
|
||||
fixture.locationStreamBatches.noShowSample.id,
|
||||
fixture.clockPoint.latitude + 0.0065,
|
||||
fixture.clockPoint.longitude + 0.0065,
|
||||
hoursFromNow(-17.9),
|
||||
]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO notification_outbox (
|
||||
id, tenant_id, business_id, shift_id, assignment_id, related_incident_id, audience_type,
|
||||
recipient_user_id, recipient_business_membership_id, channel, notification_type, priority, dedupe_key,
|
||||
subject, body, payload, status, scheduled_at, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
$1, $2, $3, $4, $5, $6, 'USER',
|
||||
bm.user_id, bm.id, 'PUSH', 'GEOFENCE_BREACH_ALERT', 'CRITICAL', $7,
|
||||
'Worker left the workplace geofence',
|
||||
'Seeded alert for coverage incident review',
|
||||
jsonb_build_object('seeded', TRUE, 'batchId', $8::text),
|
||||
'PENDING', NOW(), NOW(), NOW()
|
||||
FROM business_memberships bm
|
||||
WHERE bm.tenant_id = $2
|
||||
AND bm.business_id = $3
|
||||
AND bm.user_id = $9
|
||||
`,
|
||||
[
|
||||
fixture.notificationOutbox.noShowManagerAlert.id,
|
||||
fixture.tenant.id,
|
||||
fixture.business.id,
|
||||
fixture.shifts.noShow.id,
|
||||
fixture.assignments.noShowAna.id,
|
||||
fixture.geofenceIncidents.noShowOutsideGeofence.id,
|
||||
`seed-geofence-breach:${fixture.geofenceIncidents.noShowOutsideGeofence.id}:${fixture.users.operationsManager.id}`,
|
||||
fixture.locationStreamBatches.noShowSample.id,
|
||||
fixture.users.operationsManager.id,
|
||||
]
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(JSON.stringify({
|
||||
tenantId: fixture.tenant.id,
|
||||
businessId: fixture.business.id,
|
||||
vendorId: fixture.vendor.id,
|
||||
staffId: fixture.staff.ana.id,
|
||||
staffUserId: fixture.users.staffAna.id,
|
||||
workforceId: fixture.workforce.ana.id,
|
||||
openOrderId: fixture.orders.open.id,
|
||||
openShiftId: fixture.shifts.open.id,
|
||||
openShiftRoleId: fixture.shiftRoles.openBarista.id,
|
||||
openApplicationId: fixture.applications.openAna.id,
|
||||
completedOrderId: fixture.orders.completed.id,
|
||||
completedAssignmentId: fixture.assignments.completedAna.id,
|
||||
clockPointId: fixture.clockPoint.id,
|
||||
nfcTagUid: fixture.clockPoint.nfcTagUid,
|
||||
businessOwnerUid: fixture.users.businessOwner.id,
|
||||
}, null, 2));
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
290
backend/command-api/scripts/v2-demo-fixture.mjs
Normal file
290
backend/command-api/scripts/v2-demo-fixture.mjs
Normal file
@@ -0,0 +1,290 @@
|
||||
export const V2DemoFixture = {
|
||||
tenant: {
|
||||
id: '6d5fa42c-1f38-49be-8895-8aeb0e731001',
|
||||
slug: 'legendary-event-staffing',
|
||||
name: 'Legendary Event Staffing and Entertainment',
|
||||
},
|
||||
users: {
|
||||
businessOwner: {
|
||||
id: process.env.V2_DEMO_OWNER_UID || 'alFf9mYw3uYbm7ZjeLo1KoTgFxq2',
|
||||
email: process.env.V2_DEMO_OWNER_EMAIL || 'legendary.owner+v2@krowd.com',
|
||||
displayName: 'Legendary Demo Owner',
|
||||
},
|
||||
operationsManager: {
|
||||
id: 'demo-ops-manager',
|
||||
email: 'ops+v2@krowd.com',
|
||||
displayName: 'Wil Ops Lead',
|
||||
},
|
||||
vendorManager: {
|
||||
id: 'demo-vendor-manager',
|
||||
email: 'vendor+v2@krowd.com',
|
||||
displayName: 'Vendor Manager',
|
||||
},
|
||||
staffAna: {
|
||||
id: process.env.V2_DEMO_STAFF_UID || 'vwptrLl5S2Z598WP93cgrQEzqBg1',
|
||||
email: process.env.V2_DEMO_STAFF_EMAIL || 'ana.barista+v2@krowd.com',
|
||||
displayName: 'Ana Barista',
|
||||
},
|
||||
},
|
||||
business: {
|
||||
id: '14f4fcfb-f21f-4ba9-9328-90f794a56001',
|
||||
slug: 'google-mv-cafes',
|
||||
name: 'Google Mountain View Cafes',
|
||||
},
|
||||
vendor: {
|
||||
id: '80f8c8d3-9da8-4892-908f-4d4982af7001',
|
||||
slug: 'legendary-pool-a',
|
||||
name: 'Legendary Staffing Pool A',
|
||||
},
|
||||
costCenters: {
|
||||
cafeOps: {
|
||||
id: '31db54dd-9b32-4504-9056-9c71a9f73001',
|
||||
name: 'Cafe Operations',
|
||||
},
|
||||
},
|
||||
roles: {
|
||||
barista: {
|
||||
id: '67c5010e-85f0-4f6b-99b7-167c9afdf001',
|
||||
code: 'BARISTA',
|
||||
name: 'Barista',
|
||||
},
|
||||
captain: {
|
||||
id: '67c5010e-85f0-4f6b-99b7-167c9afdf002',
|
||||
code: 'CAPTAIN',
|
||||
name: 'Captain',
|
||||
},
|
||||
},
|
||||
staff: {
|
||||
ana: {
|
||||
id: '4b7dff1a-1856-4d59-b450-5a6736461001',
|
||||
fullName: 'Ana Barista',
|
||||
email: 'ana.barista+v2@krowd.com',
|
||||
phone: '+15557654321',
|
||||
primaryRole: 'BARISTA',
|
||||
},
|
||||
},
|
||||
workforce: {
|
||||
ana: {
|
||||
id: '4cc1d34a-87c3-4426-8ee0-a24c8bcfa001',
|
||||
workforceNumber: 'WF-V2-ANA-001',
|
||||
},
|
||||
},
|
||||
clockPoint: {
|
||||
id: 'efb80ccf-3361-49c8-bc74-ff8cd4d2e001',
|
||||
label: 'Google MV Cafe Clock Point',
|
||||
address: '1600 Amphitheatre Pkwy, Mountain View, CA',
|
||||
latitude: 37.4221,
|
||||
longitude: -122.0841,
|
||||
geofenceRadiusMeters: 120,
|
||||
nfcTagUid: 'NFC-DEMO-ANA-001',
|
||||
defaultClockInMode: 'GEO_REQUIRED',
|
||||
allowClockInOverride: true,
|
||||
},
|
||||
hubManagers: {
|
||||
opsLead: {
|
||||
id: '3f2dfd17-e6b4-4fe4-9fea-3c91c7ca8001',
|
||||
},
|
||||
},
|
||||
availability: {
|
||||
monday: {
|
||||
id: '887bc357-c3e0-4b2c-a174-bf27d6902001',
|
||||
},
|
||||
friday: {
|
||||
id: '887bc357-c3e0-4b2c-a174-bf27d6902002',
|
||||
},
|
||||
},
|
||||
benefits: {
|
||||
commuter: {
|
||||
id: 'dbd28438-66b0-452f-a5fc-dd0f3ea61001',
|
||||
title: 'Commuter Support',
|
||||
},
|
||||
},
|
||||
orders: {
|
||||
open: {
|
||||
id: 'b6132d7a-45c3-4879-b349-46b2fd518001',
|
||||
number: 'ORD-V2-OPEN-1001',
|
||||
title: 'Morning cafe staffing',
|
||||
},
|
||||
completed: {
|
||||
id: 'b6132d7a-45c3-4879-b349-46b2fd518002',
|
||||
number: 'ORD-V2-COMP-1002',
|
||||
title: 'Completed catering shift',
|
||||
},
|
||||
active: {
|
||||
id: 'b6132d7a-45c3-4879-b349-46b2fd518003',
|
||||
number: 'ORD-V2-ACT-1003',
|
||||
title: 'Live staffing operations',
|
||||
},
|
||||
},
|
||||
shifts: {
|
||||
open: {
|
||||
id: '6e7dadad-99e4-45bb-b0da-7bb617954001',
|
||||
code: 'SHIFT-V2-OPEN-1',
|
||||
title: 'Open breakfast shift',
|
||||
},
|
||||
completed: {
|
||||
id: '6e7dadad-99e4-45bb-b0da-7bb617954002',
|
||||
code: 'SHIFT-V2-COMP-1',
|
||||
title: 'Completed catering shift',
|
||||
},
|
||||
available: {
|
||||
id: '6e7dadad-99e4-45bb-b0da-7bb617954003',
|
||||
code: 'SHIFT-V2-OPEN-2',
|
||||
title: 'Available lunch shift',
|
||||
},
|
||||
assigned: {
|
||||
id: '6e7dadad-99e4-45bb-b0da-7bb617954004',
|
||||
code: 'SHIFT-V2-ASSIGNED-1',
|
||||
title: 'Assigned espresso shift',
|
||||
clockInMode: 'GEO_REQUIRED',
|
||||
allowClockInOverride: true,
|
||||
},
|
||||
cancelled: {
|
||||
id: '6e7dadad-99e4-45bb-b0da-7bb617954005',
|
||||
code: 'SHIFT-V2-CANCELLED-1',
|
||||
title: 'Cancelled hospitality shift',
|
||||
},
|
||||
noShow: {
|
||||
id: '6e7dadad-99e4-45bb-b0da-7bb617954006',
|
||||
code: 'SHIFT-V2-NOSHOW-1',
|
||||
title: 'No-show breakfast shift',
|
||||
},
|
||||
},
|
||||
shiftRoles: {
|
||||
openBarista: {
|
||||
id: '4dd35b2b-4aaf-4c28-a91f-7bda05e2b001',
|
||||
},
|
||||
completedBarista: {
|
||||
id: '4dd35b2b-4aaf-4c28-a91f-7bda05e2b002',
|
||||
},
|
||||
availableBarista: {
|
||||
id: '4dd35b2b-4aaf-4c28-a91f-7bda05e2b003',
|
||||
},
|
||||
assignedBarista: {
|
||||
id: '4dd35b2b-4aaf-4c28-a91f-7bda05e2b004',
|
||||
},
|
||||
cancelledBarista: {
|
||||
id: '4dd35b2b-4aaf-4c28-a91f-7bda05e2b005',
|
||||
},
|
||||
noShowBarista: {
|
||||
id: '4dd35b2b-4aaf-4c28-a91f-7bda05e2b006',
|
||||
},
|
||||
},
|
||||
applications: {
|
||||
openAna: {
|
||||
id: 'd70d6441-6d0c-4fdb-9a29-c9d9e0c34001',
|
||||
},
|
||||
},
|
||||
assignments: {
|
||||
completedAna: {
|
||||
id: 'f1d3f738-a132-4863-b222-4f9cb25aa001',
|
||||
},
|
||||
assignedAna: {
|
||||
id: 'f1d3f738-a132-4863-b222-4f9cb25aa002',
|
||||
},
|
||||
cancelledAna: {
|
||||
id: 'f1d3f738-a132-4863-b222-4f9cb25aa003',
|
||||
},
|
||||
noShowAna: {
|
||||
id: 'f1d3f738-a132-4863-b222-4f9cb25aa004',
|
||||
},
|
||||
},
|
||||
timesheets: {
|
||||
completedAna: {
|
||||
id: '41ea4057-0c55-4907-b525-07315b2b6001',
|
||||
},
|
||||
},
|
||||
invoices: {
|
||||
completed: {
|
||||
id: '1455e15b-77f9-4c66-b2a8-dce35f7ac001',
|
||||
number: 'INV-V2-2001',
|
||||
},
|
||||
},
|
||||
recentPayments: {
|
||||
completed: {
|
||||
id: 'be6f736b-e945-4676-a73d-2912c7575001',
|
||||
},
|
||||
},
|
||||
favorites: {
|
||||
ana: {
|
||||
id: 'ba5cb8fa-0be9-4ef4-a9fb-e60a8a48e001',
|
||||
},
|
||||
},
|
||||
reviews: {
|
||||
anaCompleted: {
|
||||
id: '9b6bc737-fd69-4855-b425-6f0c2c4fd001',
|
||||
},
|
||||
},
|
||||
documents: {
|
||||
governmentId: {
|
||||
id: 'e6fd0183-34d9-4c23-9a9a-bf98da995000',
|
||||
name: 'Government ID',
|
||||
},
|
||||
foodSafety: {
|
||||
id: 'e6fd0183-34d9-4c23-9a9a-bf98da995001',
|
||||
name: 'Food Handler Card',
|
||||
},
|
||||
attireBlackShirt: {
|
||||
id: 'e6fd0183-34d9-4c23-9a9a-bf98da995002',
|
||||
name: 'Black Shirt',
|
||||
},
|
||||
taxFormI9: {
|
||||
id: 'e6fd0183-34d9-4c23-9a9a-bf98da995003',
|
||||
name: 'I-9',
|
||||
},
|
||||
taxFormW4: {
|
||||
id: 'e6fd0183-34d9-4c23-9a9a-bf98da995004',
|
||||
name: 'W-4',
|
||||
},
|
||||
},
|
||||
staffDocuments: {
|
||||
governmentId: {
|
||||
id: '4b157236-a4b0-4c44-b199-7d4ea1f95000',
|
||||
},
|
||||
foodSafety: {
|
||||
id: '4b157236-a4b0-4c44-b199-7d4ea1f95001',
|
||||
},
|
||||
attireBlackShirt: {
|
||||
id: '4b157236-a4b0-4c44-b199-7d4ea1f95002',
|
||||
},
|
||||
taxFormI9: {
|
||||
id: '4b157236-a4b0-4c44-b199-7d4ea1f95003',
|
||||
},
|
||||
taxFormW4: {
|
||||
id: '4b157236-a4b0-4c44-b199-7d4ea1f95004',
|
||||
},
|
||||
},
|
||||
certificates: {
|
||||
foodSafety: {
|
||||
id: 'df6452dc-4ec7-4d54-876d-26bf8ce5b001',
|
||||
},
|
||||
},
|
||||
emergencyContacts: {
|
||||
primary: {
|
||||
id: '8bb1e0c0-59bb-4ce7-8f0f-27674e0b2001',
|
||||
},
|
||||
},
|
||||
accounts: {
|
||||
businessPrimary: {
|
||||
id: '5d98e0ba-8e89-4ffb-aafd-df6bbe2fe001',
|
||||
},
|
||||
staffPrimary: {
|
||||
id: '5d98e0ba-8e89-4ffb-aafd-df6bbe2fe002',
|
||||
},
|
||||
},
|
||||
locationStreamBatches: {
|
||||
noShowSample: {
|
||||
id: '7184a512-b5b2-46b7-a8e0-f4a04bb8f001',
|
||||
},
|
||||
},
|
||||
geofenceIncidents: {
|
||||
noShowOutsideGeofence: {
|
||||
id: '8174a512-b5b2-46b7-a8e0-f4a04bb8f001',
|
||||
},
|
||||
},
|
||||
notificationOutbox: {
|
||||
noShowManagerAlert: {
|
||||
id: '9174a512-b5b2-46b7-a8e0-f4a04bb8f001',
|
||||
},
|
||||
},
|
||||
};
|
||||
639
backend/command-api/sql/v2/001_v2_domain_foundation.sql
Normal file
639
backend/command-api/sql/v2/001_v2_domain_foundation.sql
Normal file
@@ -0,0 +1,639 @@
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tenants (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'ACTIVE' CHECK (status IN ('ACTIVE', 'INACTIVE')),
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT,
|
||||
display_name TEXT,
|
||||
phone TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'ACTIVE' CHECK (status IN ('ACTIVE', 'INVITED', 'DISABLED')),
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_unique
|
||||
ON users (LOWER(email))
|
||||
WHERE email IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tenant_memberships (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
invited_email TEXT,
|
||||
membership_status TEXT NOT NULL DEFAULT 'ACTIVE'
|
||||
CHECK (membership_status IN ('INVITED', 'ACTIVE', 'SUSPENDED', 'REMOVED')),
|
||||
base_role TEXT NOT NULL DEFAULT 'member'
|
||||
CHECK (base_role IN ('admin', 'manager', 'member', 'viewer')),
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT chk_tenant_membership_identity
|
||||
CHECK (user_id IS NOT NULL OR invited_email IS NOT NULL)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_tenant_memberships_tenant_user
|
||||
ON tenant_memberships (tenant_id, user_id)
|
||||
WHERE user_id IS NOT NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_tenant_memberships_tenant_invited_email
|
||||
ON tenant_memberships (tenant_id, LOWER(invited_email))
|
||||
WHERE invited_email IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS businesses (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
slug TEXT NOT NULL,
|
||||
business_name TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'ACTIVE'
|
||||
CHECK (status IN ('ACTIVE', 'INACTIVE', 'ARCHIVED')),
|
||||
contact_name TEXT,
|
||||
contact_email TEXT,
|
||||
contact_phone TEXT,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_businesses_tenant_slug
|
||||
ON businesses (tenant_id, slug);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS business_memberships (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
business_id UUID NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
|
||||
user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
invited_email TEXT,
|
||||
membership_status TEXT NOT NULL DEFAULT 'ACTIVE'
|
||||
CHECK (membership_status IN ('INVITED', 'ACTIVE', 'SUSPENDED', 'REMOVED')),
|
||||
business_role TEXT NOT NULL DEFAULT 'member'
|
||||
CHECK (business_role IN ('owner', 'manager', 'member', 'viewer')),
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT chk_business_membership_identity
|
||||
CHECK (user_id IS NOT NULL OR invited_email IS NOT NULL)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_business_memberships_business_user
|
||||
ON business_memberships (business_id, user_id)
|
||||
WHERE user_id IS NOT NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_business_memberships_business_invited_email
|
||||
ON business_memberships (business_id, LOWER(invited_email))
|
||||
WHERE invited_email IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS vendors (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
slug TEXT NOT NULL,
|
||||
company_name TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'ACTIVE'
|
||||
CHECK (status IN ('ACTIVE', 'INACTIVE', 'ARCHIVED')),
|
||||
contact_name TEXT,
|
||||
contact_email TEXT,
|
||||
contact_phone TEXT,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_vendors_tenant_slug
|
||||
ON vendors (tenant_id, slug);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS vendor_memberships (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
vendor_id UUID NOT NULL REFERENCES vendors(id) ON DELETE CASCADE,
|
||||
user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
invited_email TEXT,
|
||||
membership_status TEXT NOT NULL DEFAULT 'ACTIVE'
|
||||
CHECK (membership_status IN ('INVITED', 'ACTIVE', 'SUSPENDED', 'REMOVED')),
|
||||
vendor_role TEXT NOT NULL DEFAULT 'member'
|
||||
CHECK (vendor_role IN ('owner', 'manager', 'member', 'viewer')),
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT chk_vendor_membership_identity
|
||||
CHECK (user_id IS NOT NULL OR invited_email IS NOT NULL)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_vendor_memberships_vendor_user
|
||||
ON vendor_memberships (vendor_id, user_id)
|
||||
WHERE user_id IS NOT NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_vendor_memberships_vendor_invited_email
|
||||
ON vendor_memberships (vendor_id, LOWER(invited_email))
|
||||
WHERE invited_email IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS staffs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
full_name TEXT NOT NULL,
|
||||
email TEXT,
|
||||
phone TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'ACTIVE'
|
||||
CHECK (status IN ('ACTIVE', 'INVITED', 'INACTIVE', 'BLOCKED')),
|
||||
primary_role TEXT,
|
||||
onboarding_status TEXT NOT NULL DEFAULT 'PENDING'
|
||||
CHECK (onboarding_status IN ('PENDING', 'IN_PROGRESS', 'COMPLETED')),
|
||||
average_rating NUMERIC(3, 2) NOT NULL DEFAULT 0 CHECK (average_rating >= 0 AND average_rating <= 5),
|
||||
rating_count INTEGER NOT NULL DEFAULT 0 CHECK (rating_count >= 0),
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_staffs_tenant_user
|
||||
ON staffs (tenant_id, user_id)
|
||||
WHERE user_id IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workforce (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
vendor_id UUID NOT NULL REFERENCES vendors(id) ON DELETE CASCADE,
|
||||
staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE CASCADE,
|
||||
workforce_number TEXT NOT NULL,
|
||||
employment_type TEXT NOT NULL
|
||||
CHECK (employment_type IN ('W2', 'W1099', 'TEMP', 'CONTRACT')),
|
||||
status TEXT NOT NULL DEFAULT 'ACTIVE'
|
||||
CHECK (status IN ('ACTIVE', 'INACTIVE', 'SUSPENDED')),
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_workforce_vendor_staff
|
||||
ON workforce (vendor_id, staff_id);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_workforce_number_tenant
|
||||
ON workforce (tenant_id, workforce_number);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS roles_catalog (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
code TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'ACTIVE'
|
||||
CHECK (status IN ('ACTIVE', 'INACTIVE')),
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_roles_catalog_tenant_code
|
||||
ON roles_catalog (tenant_id, code);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS staff_roles (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE CASCADE,
|
||||
role_id UUID NOT NULL REFERENCES roles_catalog(id) ON DELETE CASCADE,
|
||||
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_staff_roles_staff_role
|
||||
ON staff_roles (staff_id, role_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS clock_points (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
business_id UUID REFERENCES businesses(id) ON DELETE SET NULL,
|
||||
label TEXT NOT NULL,
|
||||
address TEXT,
|
||||
latitude NUMERIC(9, 6),
|
||||
longitude NUMERIC(9, 6),
|
||||
geofence_radius_meters INTEGER NOT NULL DEFAULT 100 CHECK (geofence_radius_meters > 0),
|
||||
nfc_tag_uid TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'ACTIVE'
|
||||
CHECK (status IN ('ACTIVE', 'INACTIVE')),
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_clock_points_tenant_nfc_tag
|
||||
ON clock_points (tenant_id, nfc_tag_uid)
|
||||
WHERE nfc_tag_uid IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
business_id UUID NOT NULL REFERENCES businesses(id) ON DELETE RESTRICT,
|
||||
vendor_id UUID REFERENCES vendors(id) ON DELETE SET NULL,
|
||||
order_number TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'DRAFT'
|
||||
CHECK (status IN ('DRAFT', 'OPEN', 'FILLED', 'ACTIVE', 'COMPLETED', 'CANCELLED')),
|
||||
service_type TEXT NOT NULL DEFAULT 'EVENT'
|
||||
CHECK (service_type IN ('EVENT', 'CATERING', 'HOTEL', 'RESTAURANT', 'OTHER')),
|
||||
starts_at TIMESTAMPTZ,
|
||||
ends_at TIMESTAMPTZ,
|
||||
location_name TEXT,
|
||||
location_address TEXT,
|
||||
latitude NUMERIC(9, 6),
|
||||
longitude NUMERIC(9, 6),
|
||||
notes TEXT,
|
||||
created_by_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT chk_orders_time_window CHECK (starts_at IS NULL OR ends_at IS NULL OR starts_at < ends_at)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_orders_tenant_order_number
|
||||
ON orders (tenant_id, order_number);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_tenant_business_status
|
||||
ON orders (tenant_id, business_id, status, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS shifts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
|
||||
business_id UUID NOT NULL REFERENCES businesses(id) ON DELETE RESTRICT,
|
||||
vendor_id UUID REFERENCES vendors(id) ON DELETE SET NULL,
|
||||
clock_point_id UUID REFERENCES clock_points(id) ON DELETE SET NULL,
|
||||
shift_code TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'OPEN'
|
||||
CHECK (status IN ('DRAFT', 'OPEN', 'PENDING_CONFIRMATION', 'ASSIGNED', 'ACTIVE', 'COMPLETED', 'CANCELLED')),
|
||||
starts_at TIMESTAMPTZ NOT NULL,
|
||||
ends_at TIMESTAMPTZ NOT NULL,
|
||||
timezone TEXT NOT NULL DEFAULT 'UTC',
|
||||
location_name TEXT,
|
||||
location_address TEXT,
|
||||
latitude NUMERIC(9, 6),
|
||||
longitude NUMERIC(9, 6),
|
||||
geofence_radius_meters INTEGER CHECK (geofence_radius_meters IS NULL OR geofence_radius_meters > 0),
|
||||
required_workers INTEGER NOT NULL DEFAULT 1 CHECK (required_workers > 0),
|
||||
assigned_workers INTEGER NOT NULL DEFAULT 0 CHECK (assigned_workers >= 0),
|
||||
notes TEXT,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT chk_shifts_time_window CHECK (starts_at < ends_at),
|
||||
CONSTRAINT chk_shifts_assigned_workers CHECK (assigned_workers <= required_workers)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_shifts_order_shift_code
|
||||
ON shifts (order_id, shift_code);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_shifts_tenant_time
|
||||
ON shifts (tenant_id, starts_at, ends_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS shift_roles (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
shift_id UUID NOT NULL REFERENCES shifts(id) ON DELETE CASCADE,
|
||||
role_id UUID REFERENCES roles_catalog(id) ON DELETE SET NULL,
|
||||
role_code TEXT NOT NULL,
|
||||
role_name TEXT NOT NULL,
|
||||
workers_needed INTEGER NOT NULL CHECK (workers_needed > 0),
|
||||
assigned_count INTEGER NOT NULL DEFAULT 0 CHECK (assigned_count >= 0),
|
||||
pay_rate_cents INTEGER NOT NULL DEFAULT 0 CHECK (pay_rate_cents >= 0),
|
||||
bill_rate_cents INTEGER NOT NULL DEFAULT 0 CHECK (bill_rate_cents >= 0),
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT chk_shift_roles_assigned_count CHECK (assigned_count <= workers_needed)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_shift_roles_shift_role_code
|
||||
ON shift_roles (shift_id, role_code);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS applications (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
shift_id UUID NOT NULL REFERENCES shifts(id) ON DELETE CASCADE,
|
||||
shift_role_id UUID NOT NULL REFERENCES shift_roles(id) ON DELETE CASCADE,
|
||||
staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL DEFAULT 'PENDING'
|
||||
CHECK (status IN ('PENDING', 'CONFIRMED', 'CHECKED_IN', 'LATE', 'NO_SHOW', 'COMPLETED', 'REJECTED', 'CANCELLED')),
|
||||
origin TEXT NOT NULL DEFAULT 'STAFF'
|
||||
CHECK (origin IN ('STAFF', 'BUSINESS', 'VENDOR', 'SYSTEM')),
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_applications_shift_role_staff
|
||||
ON applications (shift_role_id, staff_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_applications_staff_status
|
||||
ON applications (staff_id, status, applied_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS assignments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
business_id UUID NOT NULL REFERENCES businesses(id) ON DELETE RESTRICT,
|
||||
vendor_id UUID REFERENCES vendors(id) ON DELETE SET NULL,
|
||||
shift_id UUID NOT NULL REFERENCES shifts(id) ON DELETE CASCADE,
|
||||
shift_role_id UUID NOT NULL REFERENCES shift_roles(id) ON DELETE CASCADE,
|
||||
workforce_id UUID NOT NULL REFERENCES workforce(id) ON DELETE RESTRICT,
|
||||
staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE RESTRICT,
|
||||
application_id UUID REFERENCES applications(id) ON DELETE SET NULL,
|
||||
status TEXT NOT NULL DEFAULT 'ASSIGNED'
|
||||
CHECK (status IN ('ASSIGNED', 'ACCEPTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED', 'CANCELLED', 'NO_SHOW')),
|
||||
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
accepted_at TIMESTAMPTZ,
|
||||
checked_in_at TIMESTAMPTZ,
|
||||
checked_out_at TIMESTAMPTZ,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_assignments_shift_role_workforce
|
||||
ON assignments (shift_role_id, workforce_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_assignments_staff_status
|
||||
ON assignments (staff_id, status, assigned_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS attendance_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
assignment_id UUID NOT NULL REFERENCES assignments(id) ON DELETE CASCADE,
|
||||
shift_id UUID NOT NULL REFERENCES shifts(id) ON DELETE CASCADE,
|
||||
staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE RESTRICT,
|
||||
clock_point_id UUID REFERENCES clock_points(id) ON DELETE SET NULL,
|
||||
event_type TEXT NOT NULL
|
||||
CHECK (event_type IN ('CLOCK_IN', 'CLOCK_OUT', 'MANUAL_ADJUSTMENT')),
|
||||
source_type TEXT NOT NULL
|
||||
CHECK (source_type IN ('NFC', 'GEO', 'QR', 'MANUAL', 'SYSTEM')),
|
||||
source_reference TEXT,
|
||||
nfc_tag_uid TEXT,
|
||||
device_id TEXT,
|
||||
latitude NUMERIC(9, 6),
|
||||
longitude NUMERIC(9, 6),
|
||||
accuracy_meters INTEGER CHECK (accuracy_meters IS NULL OR accuracy_meters >= 0),
|
||||
distance_to_clock_point_meters INTEGER CHECK (distance_to_clock_point_meters IS NULL OR distance_to_clock_point_meters >= 0),
|
||||
within_geofence BOOLEAN,
|
||||
validation_status TEXT NOT NULL DEFAULT 'ACCEPTED'
|
||||
CHECK (validation_status IN ('ACCEPTED', 'FLAGGED', 'REJECTED')),
|
||||
validation_reason TEXT,
|
||||
captured_at TIMESTAMPTZ NOT NULL,
|
||||
raw_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_attendance_events_assignment_time
|
||||
ON attendance_events (assignment_id, captured_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_attendance_events_staff_time
|
||||
ON attendance_events (staff_id, captured_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS attendance_sessions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
assignment_id UUID NOT NULL UNIQUE REFERENCES assignments(id) ON DELETE CASCADE,
|
||||
staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE RESTRICT,
|
||||
clock_in_event_id UUID REFERENCES attendance_events(id) ON DELETE SET NULL,
|
||||
clock_out_event_id UUID REFERENCES attendance_events(id) ON DELETE SET NULL,
|
||||
status TEXT NOT NULL DEFAULT 'OPEN'
|
||||
CHECK (status IN ('OPEN', 'CLOSED', 'DISPUTED')),
|
||||
check_in_at TIMESTAMPTZ,
|
||||
check_out_at TIMESTAMPTZ,
|
||||
worked_minutes INTEGER NOT NULL DEFAULT 0 CHECK (worked_minutes >= 0),
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS timesheets (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
assignment_id UUID NOT NULL UNIQUE REFERENCES assignments(id) ON DELETE CASCADE,
|
||||
staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE RESTRICT,
|
||||
status TEXT NOT NULL DEFAULT 'PENDING'
|
||||
CHECK (status IN ('PENDING', 'SUBMITTED', 'APPROVED', 'REJECTED', 'PAID')),
|
||||
regular_minutes INTEGER NOT NULL DEFAULT 0 CHECK (regular_minutes >= 0),
|
||||
overtime_minutes INTEGER NOT NULL DEFAULT 0 CHECK (overtime_minutes >= 0),
|
||||
break_minutes INTEGER NOT NULL DEFAULT 0 CHECK (break_minutes >= 0),
|
||||
gross_pay_cents BIGINT NOT NULL DEFAULT 0 CHECK (gross_pay_cents >= 0),
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS documents (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
document_type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
required_for_role_code TEXT,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_documents_tenant_type_name
|
||||
ON documents (tenant_id, document_type, name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS staff_documents (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE CASCADE,
|
||||
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
file_uri TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'PENDING'
|
||||
CHECK (status IN ('PENDING', 'VERIFIED', 'REJECTED', 'EXPIRED')),
|
||||
expires_at TIMESTAMPTZ,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_staff_documents_staff_document
|
||||
ON staff_documents (staff_id, document_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS certificates (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE CASCADE,
|
||||
certificate_type TEXT NOT NULL,
|
||||
certificate_number TEXT,
|
||||
issued_at TIMESTAMPTZ,
|
||||
expires_at TIMESTAMPTZ,
|
||||
status TEXT NOT NULL DEFAULT 'PENDING'
|
||||
CHECK (status IN ('PENDING', 'VERIFIED', 'REJECTED', 'EXPIRED')),
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS verification_jobs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
staff_id UUID REFERENCES staffs(id) ON DELETE SET NULL,
|
||||
document_id UUID REFERENCES documents(id) ON DELETE SET NULL,
|
||||
type TEXT NOT NULL,
|
||||
file_uri TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'PENDING'
|
||||
CHECK (status IN ('PENDING', 'PROCESSING', 'AUTO_PASS', 'AUTO_FAIL', 'NEEDS_REVIEW', 'APPROVED', 'REJECTED', 'ERROR')),
|
||||
idempotency_key TEXT,
|
||||
provider_name TEXT,
|
||||
provider_reference TEXT,
|
||||
confidence NUMERIC(4, 3),
|
||||
reasons JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
extracted JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
review JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_verification_jobs_tenant_idempotency
|
||||
ON verification_jobs (tenant_id, idempotency_key)
|
||||
WHERE idempotency_key IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS verification_reviews (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
verification_job_id UUID NOT NULL REFERENCES verification_jobs(id) ON DELETE CASCADE,
|
||||
reviewer_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
decision TEXT NOT NULL CHECK (decision IN ('APPROVED', 'REJECTED')),
|
||||
note TEXT,
|
||||
reason_code TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS verification_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
verification_job_id UUID NOT NULL REFERENCES verification_jobs(id) ON DELETE CASCADE,
|
||||
from_status TEXT,
|
||||
to_status TEXT NOT NULL,
|
||||
actor_type TEXT NOT NULL,
|
||||
actor_id TEXT,
|
||||
details JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
owner_type TEXT NOT NULL CHECK (owner_type IN ('BUSINESS', 'VENDOR', 'STAFF')),
|
||||
owner_business_id UUID REFERENCES businesses(id) ON DELETE CASCADE,
|
||||
owner_vendor_id UUID REFERENCES vendors(id) ON DELETE CASCADE,
|
||||
owner_staff_id UUID REFERENCES staffs(id) ON DELETE CASCADE,
|
||||
provider_name TEXT NOT NULL,
|
||||
provider_reference TEXT NOT NULL,
|
||||
last4 TEXT,
|
||||
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT chk_accounts_single_owner
|
||||
CHECK (
|
||||
(owner_business_id IS NOT NULL)::INTEGER
|
||||
+ (owner_vendor_id IS NOT NULL)::INTEGER
|
||||
+ (owner_staff_id IS NOT NULL)::INTEGER = 1
|
||||
)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_owner_primary_business
|
||||
ON accounts (owner_business_id)
|
||||
WHERE owner_business_id IS NOT NULL AND is_primary = TRUE;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_owner_primary_vendor
|
||||
ON accounts (owner_vendor_id)
|
||||
WHERE owner_vendor_id IS NOT NULL AND is_primary = TRUE;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_owner_primary_staff
|
||||
ON accounts (owner_staff_id)
|
||||
WHERE owner_staff_id IS NOT NULL AND is_primary = TRUE;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS invoices (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
|
||||
business_id UUID NOT NULL REFERENCES businesses(id) ON DELETE RESTRICT,
|
||||
vendor_id UUID REFERENCES vendors(id) ON DELETE SET NULL,
|
||||
invoice_number TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'PENDING'
|
||||
CHECK (status IN ('DRAFT', 'PENDING', 'PENDING_REVIEW', 'APPROVED', 'PAID', 'OVERDUE', 'DISPUTED', 'VOID')),
|
||||
currency_code TEXT NOT NULL DEFAULT 'USD',
|
||||
subtotal_cents BIGINT NOT NULL DEFAULT 0 CHECK (subtotal_cents >= 0),
|
||||
tax_cents BIGINT NOT NULL DEFAULT 0 CHECK (tax_cents >= 0),
|
||||
total_cents BIGINT NOT NULL DEFAULT 0 CHECK (total_cents >= 0),
|
||||
due_at TIMESTAMPTZ,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_invoices_tenant_invoice_number
|
||||
ON invoices (tenant_id, invoice_number);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS recent_payments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
invoice_id UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
|
||||
assignment_id UUID REFERENCES assignments(id) ON DELETE SET NULL,
|
||||
staff_id UUID REFERENCES staffs(id) ON DELETE SET NULL,
|
||||
status TEXT NOT NULL DEFAULT 'PENDING'
|
||||
CHECK (status IN ('PENDING', 'PROCESSING', 'PAID', 'FAILED')),
|
||||
amount_cents BIGINT NOT NULL CHECK (amount_cents >= 0),
|
||||
process_date TIMESTAMPTZ,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS staff_reviews (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
business_id UUID NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
|
||||
staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE CASCADE,
|
||||
assignment_id UUID NOT NULL REFERENCES assignments(id) ON DELETE CASCADE,
|
||||
reviewer_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
rating SMALLINT NOT NULL CHECK (rating BETWEEN 1 AND 5),
|
||||
review_text TEXT,
|
||||
tags JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_staff_reviews_business_assignment_staff
|
||||
ON staff_reviews (business_id, assignment_id, staff_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_staff_reviews_staff_created_at
|
||||
ON staff_reviews (staff_id, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS staff_favorites (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
business_id UUID NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
|
||||
staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE CASCADE,
|
||||
created_by_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_staff_favorites_business_staff
|
||||
ON staff_favorites (business_id, staff_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS domain_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
aggregate_type TEXT NOT NULL,
|
||||
aggregate_id UUID NOT NULL,
|
||||
sequence INTEGER NOT NULL CHECK (sequence > 0),
|
||||
event_type TEXT NOT NULL,
|
||||
actor_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_domain_events_aggregate_sequence
|
||||
ON domain_events (tenant_id, aggregate_type, aggregate_id, sequence);
|
||||
64
backend/command-api/sql/v2/002_v2_mobile_support.sql
Normal file
64
backend/command-api/sql/v2/002_v2_mobile_support.sql
Normal file
@@ -0,0 +1,64 @@
|
||||
CREATE TABLE IF NOT EXISTS cost_centers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
business_id UUID NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
|
||||
code TEXT,
|
||||
name TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'ACTIVE'
|
||||
CHECK (status IN ('ACTIVE', 'INACTIVE', 'ARCHIVED')),
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_cost_centers_business_name
|
||||
ON cost_centers (business_id, name);
|
||||
|
||||
ALTER TABLE clock_points
|
||||
ADD COLUMN IF NOT EXISTS cost_center_id UUID REFERENCES cost_centers(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS hub_managers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
hub_id UUID NOT NULL REFERENCES clock_points(id) ON DELETE CASCADE,
|
||||
business_membership_id UUID NOT NULL REFERENCES business_memberships(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_hub_managers_hub_membership
|
||||
ON hub_managers (hub_id, business_membership_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS staff_availability (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE CASCADE,
|
||||
day_of_week SMALLINT NOT NULL CHECK (day_of_week BETWEEN 0 AND 6),
|
||||
availability_status TEXT NOT NULL DEFAULT 'UNAVAILABLE'
|
||||
CHECK (availability_status IN ('AVAILABLE', 'UNAVAILABLE', 'PARTIAL')),
|
||||
time_slots JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_staff_availability_staff_day
|
||||
ON staff_availability (staff_id, day_of_week);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS staff_benefits (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE CASCADE,
|
||||
benefit_type TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'ACTIVE'
|
||||
CHECK (status IN ('ACTIVE', 'INACTIVE', 'PENDING')),
|
||||
tracked_hours INTEGER NOT NULL DEFAULT 0 CHECK (tracked_hours >= 0),
|
||||
target_hours INTEGER NOT NULL DEFAULT 0 CHECK (target_hours >= 0),
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_staff_benefits_staff_type
|
||||
ON staff_benefits (staff_id, benefit_type);
|
||||
44
backend/command-api/sql/v2/003_v2_mobile_workflows.sql
Normal file
44
backend/command-api/sql/v2/003_v2_mobile_workflows.sql
Normal file
@@ -0,0 +1,44 @@
|
||||
CREATE TABLE IF NOT EXISTS emergency_contacts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE CASCADE,
|
||||
full_name TEXT NOT NULL,
|
||||
phone TEXT NOT NULL,
|
||||
relationship_type TEXT NOT NULL,
|
||||
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_emergency_contacts_staff
|
||||
ON emergency_contacts (staff_id, created_at DESC);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_emergency_contacts_primary_staff
|
||||
ON emergency_contacts (staff_id)
|
||||
WHERE is_primary = TRUE;
|
||||
|
||||
ALTER TABLE assignments
|
||||
DROP CONSTRAINT IF EXISTS assignments_status_check;
|
||||
|
||||
ALTER TABLE assignments
|
||||
ADD CONSTRAINT assignments_status_check
|
||||
CHECK (status IN ('ASSIGNED', 'ACCEPTED', 'SWAP_REQUESTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED', 'CANCELLED', 'NO_SHOW'));
|
||||
|
||||
ALTER TABLE verification_jobs
|
||||
ADD COLUMN IF NOT EXISTS owner_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
ADD COLUMN IF NOT EXISTS subject_type TEXT,
|
||||
ADD COLUMN IF NOT EXISTS subject_id TEXT;
|
||||
|
||||
ALTER TABLE staff_documents
|
||||
ADD COLUMN IF NOT EXISTS verification_job_id UUID REFERENCES verification_jobs(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE certificates
|
||||
ADD COLUMN IF NOT EXISTS file_uri TEXT,
|
||||
ADD COLUMN IF NOT EXISTS verification_job_id UUID REFERENCES verification_jobs(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_verification_jobs_owner
|
||||
ON verification_jobs (owner_user_id, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_verification_jobs_subject
|
||||
ON verification_jobs (subject_type, subject_id, created_at DESC);
|
||||
@@ -0,0 +1,155 @@
|
||||
ALTER TABLE clock_points
|
||||
ADD COLUMN IF NOT EXISTS default_clock_in_mode TEXT,
|
||||
ADD COLUMN IF NOT EXISTS allow_clock_in_override BOOLEAN;
|
||||
|
||||
UPDATE clock_points
|
||||
SET default_clock_in_mode = COALESCE(default_clock_in_mode, 'EITHER'),
|
||||
allow_clock_in_override = COALESCE(allow_clock_in_override, TRUE)
|
||||
WHERE default_clock_in_mode IS NULL
|
||||
OR allow_clock_in_override IS NULL;
|
||||
|
||||
ALTER TABLE clock_points
|
||||
ALTER COLUMN default_clock_in_mode SET DEFAULT 'EITHER',
|
||||
ALTER COLUMN default_clock_in_mode SET NOT NULL,
|
||||
ALTER COLUMN allow_clock_in_override SET DEFAULT TRUE,
|
||||
ALTER COLUMN allow_clock_in_override SET NOT NULL;
|
||||
|
||||
ALTER TABLE clock_points
|
||||
DROP CONSTRAINT IF EXISTS clock_points_default_clock_in_mode_check;
|
||||
|
||||
ALTER TABLE clock_points
|
||||
ADD CONSTRAINT clock_points_default_clock_in_mode_check
|
||||
CHECK (default_clock_in_mode IN ('NFC_REQUIRED', 'GEO_REQUIRED', 'EITHER'));
|
||||
|
||||
ALTER TABLE shifts
|
||||
ADD COLUMN IF NOT EXISTS clock_in_mode TEXT,
|
||||
ADD COLUMN IF NOT EXISTS allow_clock_in_override BOOLEAN;
|
||||
|
||||
ALTER TABLE shifts
|
||||
DROP CONSTRAINT IF EXISTS shifts_clock_in_mode_check;
|
||||
|
||||
ALTER TABLE shifts
|
||||
ADD CONSTRAINT shifts_clock_in_mode_check
|
||||
CHECK (clock_in_mode IS NULL OR clock_in_mode IN ('NFC_REQUIRED', 'GEO_REQUIRED', 'EITHER'));
|
||||
|
||||
CREATE TABLE IF NOT EXISTS location_stream_batches (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
business_id UUID REFERENCES businesses(id) ON DELETE SET NULL,
|
||||
vendor_id UUID REFERENCES vendors(id) ON DELETE SET NULL,
|
||||
shift_id UUID NOT NULL REFERENCES shifts(id) ON DELETE CASCADE,
|
||||
assignment_id UUID NOT NULL REFERENCES assignments(id) ON DELETE CASCADE,
|
||||
staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE RESTRICT,
|
||||
actor_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
source_type TEXT NOT NULL DEFAULT 'GEO'
|
||||
CHECK (source_type IN ('NFC', 'GEO', 'QR', 'MANUAL', 'SYSTEM')),
|
||||
device_id TEXT,
|
||||
object_uri TEXT,
|
||||
point_count INTEGER NOT NULL DEFAULT 0 CHECK (point_count >= 0),
|
||||
out_of_geofence_count INTEGER NOT NULL DEFAULT 0 CHECK (out_of_geofence_count >= 0),
|
||||
missing_coordinate_count INTEGER NOT NULL DEFAULT 0 CHECK (missing_coordinate_count >= 0),
|
||||
max_distance_to_clock_point_meters INTEGER CHECK (max_distance_to_clock_point_meters IS NULL OR max_distance_to_clock_point_meters >= 0),
|
||||
started_at TIMESTAMPTZ,
|
||||
ended_at TIMESTAMPTZ,
|
||||
received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_location_stream_batches_assignment_received
|
||||
ON location_stream_batches (assignment_id, received_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_location_stream_batches_staff_received
|
||||
ON location_stream_batches (staff_id, received_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS geofence_incidents (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
business_id UUID REFERENCES businesses(id) ON DELETE SET NULL,
|
||||
vendor_id UUID REFERENCES vendors(id) ON DELETE SET NULL,
|
||||
shift_id UUID NOT NULL REFERENCES shifts(id) ON DELETE CASCADE,
|
||||
assignment_id UUID REFERENCES assignments(id) ON DELETE SET NULL,
|
||||
staff_id UUID REFERENCES staffs(id) ON DELETE SET NULL,
|
||||
actor_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
location_stream_batch_id UUID REFERENCES location_stream_batches(id) ON DELETE SET NULL,
|
||||
incident_type TEXT NOT NULL
|
||||
CHECK (incident_type IN ('CLOCK_IN_OVERRIDE', 'OUTSIDE_GEOFENCE', 'LOCATION_UNAVAILABLE', 'NFC_MISMATCH', 'CLOCK_IN_REJECTED')),
|
||||
severity TEXT NOT NULL DEFAULT 'WARNING'
|
||||
CHECK (severity IN ('INFO', 'WARNING', 'CRITICAL')),
|
||||
status TEXT NOT NULL DEFAULT 'OPEN'
|
||||
CHECK (status IN ('OPEN', 'ACKNOWLEDGED', 'RESOLVED')),
|
||||
effective_clock_in_mode TEXT
|
||||
CHECK (effective_clock_in_mode IS NULL OR effective_clock_in_mode IN ('NFC_REQUIRED', 'GEO_REQUIRED', 'EITHER')),
|
||||
source_type TEXT
|
||||
CHECK (source_type IS NULL OR source_type IN ('NFC', 'GEO', 'QR', 'MANUAL', 'SYSTEM')),
|
||||
nfc_tag_uid TEXT,
|
||||
device_id TEXT,
|
||||
latitude NUMERIC(9, 6),
|
||||
longitude NUMERIC(9, 6),
|
||||
accuracy_meters INTEGER CHECK (accuracy_meters IS NULL OR accuracy_meters >= 0),
|
||||
distance_to_clock_point_meters INTEGER CHECK (distance_to_clock_point_meters IS NULL OR distance_to_clock_point_meters >= 0),
|
||||
within_geofence BOOLEAN,
|
||||
override_reason TEXT,
|
||||
message TEXT,
|
||||
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_geofence_incidents_assignment_occurred
|
||||
ON geofence_incidents (assignment_id, occurred_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_geofence_incidents_shift_occurred
|
||||
ON geofence_incidents (shift_id, occurred_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_geofence_incidents_staff_occurred
|
||||
ON geofence_incidents (staff_id, occurred_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notification_outbox (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
business_id UUID REFERENCES businesses(id) ON DELETE SET NULL,
|
||||
shift_id UUID REFERENCES shifts(id) ON DELETE SET NULL,
|
||||
assignment_id UUID REFERENCES assignments(id) ON DELETE SET NULL,
|
||||
related_incident_id UUID REFERENCES geofence_incidents(id) ON DELETE SET NULL,
|
||||
audience_type TEXT NOT NULL DEFAULT 'USER'
|
||||
CHECK (audience_type IN ('USER', 'STAFF', 'BUSINESS_MEMBERSHIP', 'SYSTEM')),
|
||||
recipient_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
recipient_staff_id UUID REFERENCES staffs(id) ON DELETE SET NULL,
|
||||
recipient_business_membership_id UUID REFERENCES business_memberships(id) ON DELETE SET NULL,
|
||||
channel TEXT NOT NULL DEFAULT 'PUSH'
|
||||
CHECK (channel IN ('PUSH', 'EMAIL', 'SMS', 'IN_APP', 'WEBHOOK')),
|
||||
notification_type TEXT NOT NULL,
|
||||
priority TEXT NOT NULL DEFAULT 'NORMAL'
|
||||
CHECK (priority IN ('LOW', 'NORMAL', 'HIGH', 'CRITICAL')),
|
||||
dedupe_key TEXT,
|
||||
subject TEXT,
|
||||
body TEXT,
|
||||
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
status TEXT NOT NULL DEFAULT 'PENDING'
|
||||
CHECK (status IN ('PENDING', 'PROCESSING', 'SENT', 'FAILED', 'CANCELLED')),
|
||||
attempts INTEGER NOT NULL DEFAULT 0 CHECK (attempts >= 0),
|
||||
scheduled_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
sent_at TIMESTAMPTZ,
|
||||
last_error TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT chk_notification_outbox_recipient
|
||||
CHECK (
|
||||
recipient_user_id IS NOT NULL
|
||||
OR recipient_staff_id IS NOT NULL
|
||||
OR recipient_business_membership_id IS NOT NULL
|
||||
OR audience_type = 'SYSTEM'
|
||||
)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_notification_outbox_dedupe
|
||||
ON notification_outbox (dedupe_key);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notification_outbox_status_schedule
|
||||
ON notification_outbox (status, scheduled_at ASC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notification_outbox_recipient_user
|
||||
ON notification_outbox (recipient_user_id, created_at DESC)
|
||||
WHERE recipient_user_id IS NOT NULL;
|
||||
@@ -0,0 +1,4 @@
|
||||
DROP INDEX IF EXISTS idx_notification_outbox_dedupe;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_notification_outbox_dedupe
|
||||
ON notification_outbox (dedupe_key);
|
||||
@@ -0,0 +1,107 @@
|
||||
CREATE TABLE IF NOT EXISTS device_push_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
staff_id UUID REFERENCES staffs(id) ON DELETE SET NULL,
|
||||
business_membership_id UUID REFERENCES business_memberships(id) ON DELETE SET NULL,
|
||||
vendor_membership_id UUID REFERENCES vendor_memberships(id) ON DELETE SET NULL,
|
||||
provider TEXT NOT NULL DEFAULT 'FCM'
|
||||
CHECK (provider IN ('FCM', 'APNS', 'WEB_PUSH')),
|
||||
platform TEXT NOT NULL
|
||||
CHECK (platform IN ('IOS', 'ANDROID', 'WEB')),
|
||||
push_token TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL,
|
||||
device_id TEXT,
|
||||
app_version TEXT,
|
||||
app_build TEXT,
|
||||
locale TEXT,
|
||||
timezone TEXT,
|
||||
notifications_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
invalidated_at TIMESTAMPTZ,
|
||||
invalidation_reason TEXT,
|
||||
last_registered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_delivery_at TIMESTAMPTZ,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT chk_device_push_tokens_membership_scope
|
||||
CHECK (
|
||||
business_membership_id IS NOT NULL
|
||||
OR vendor_membership_id IS NOT NULL
|
||||
OR staff_id IS NOT NULL
|
||||
OR user_id IS NOT NULL
|
||||
)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_device_push_tokens_provider_hash
|
||||
ON device_push_tokens (provider, token_hash);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_device_push_tokens_user_active
|
||||
ON device_push_tokens (user_id, last_seen_at DESC)
|
||||
WHERE invalidated_at IS NULL AND notifications_enabled = TRUE;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_device_push_tokens_staff_active
|
||||
ON device_push_tokens (staff_id, last_seen_at DESC)
|
||||
WHERE staff_id IS NOT NULL AND invalidated_at IS NULL AND notifications_enabled = TRUE;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notification_deliveries (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
notification_outbox_id UUID NOT NULL REFERENCES notification_outbox(id) ON DELETE CASCADE,
|
||||
device_push_token_id UUID REFERENCES device_push_tokens(id) ON DELETE SET NULL,
|
||||
provider TEXT NOT NULL DEFAULT 'FCM'
|
||||
CHECK (provider IN ('FCM', 'APNS', 'WEB_PUSH')),
|
||||
delivery_status TEXT NOT NULL
|
||||
CHECK (delivery_status IN ('SIMULATED', 'SENT', 'FAILED', 'INVALID_TOKEN', 'SKIPPED')),
|
||||
provider_message_id TEXT,
|
||||
attempt_number INTEGER NOT NULL DEFAULT 1 CHECK (attempt_number >= 1),
|
||||
error_code TEXT,
|
||||
error_message TEXT,
|
||||
response_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
sent_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notification_deliveries_outbox_created
|
||||
ON notification_deliveries (notification_outbox_id, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notification_deliveries_token_created
|
||||
ON notification_deliveries (device_push_token_id, created_at DESC)
|
||||
WHERE device_push_token_id IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS attendance_security_proofs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
assignment_id UUID NOT NULL REFERENCES assignments(id) ON DELETE CASCADE,
|
||||
shift_id UUID NOT NULL REFERENCES shifts(id) ON DELETE CASCADE,
|
||||
staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE RESTRICT,
|
||||
actor_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
event_type TEXT NOT NULL
|
||||
CHECK (event_type IN ('CLOCK_IN', 'CLOCK_OUT')),
|
||||
source_type TEXT NOT NULL
|
||||
CHECK (source_type IN ('NFC', 'GEO', 'QR', 'MANUAL', 'SYSTEM')),
|
||||
device_id TEXT,
|
||||
nfc_tag_uid TEXT,
|
||||
proof_nonce TEXT,
|
||||
proof_timestamp TIMESTAMPTZ,
|
||||
request_fingerprint TEXT,
|
||||
attestation_provider TEXT
|
||||
CHECK (attestation_provider IS NULL OR attestation_provider IN ('PLAY_INTEGRITY', 'APP_ATTEST', 'DEVICE_CHECK')),
|
||||
attestation_token_hash TEXT,
|
||||
attestation_status TEXT NOT NULL DEFAULT 'NOT_PROVIDED'
|
||||
CHECK (attestation_status IN ('NOT_PROVIDED', 'RECORDED_UNVERIFIED', 'VERIFIED', 'REJECTED', 'BYPASSED')),
|
||||
attestation_reason TEXT,
|
||||
object_uri TEXT,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_attendance_security_proofs_nonce
|
||||
ON attendance_security_proofs (tenant_id, proof_nonce)
|
||||
WHERE proof_nonce IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_attendance_security_proofs_assignment_created
|
||||
ON attendance_security_proofs (assignment_id, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_attendance_security_proofs_staff_created
|
||||
ON attendance_security_proofs (staff_id, created_at DESC);
|
||||
@@ -5,10 +5,11 @@ import { requestContext } from './middleware/request-context.js';
|
||||
import { errorHandler, notFoundHandler } from './middleware/error-handler.js';
|
||||
import { healthRouter } from './routes/health.js';
|
||||
import { createCommandsRouter } from './routes/commands.js';
|
||||
import { createMobileCommandsRouter } from './routes/mobile.js';
|
||||
|
||||
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
|
||||
|
||||
export function createApp() {
|
||||
export function createApp(options = {}) {
|
||||
const app = express();
|
||||
|
||||
app.use(requestContext);
|
||||
@@ -21,7 +22,8 @@ export function createApp() {
|
||||
app.use(express.json({ limit: '2mb' }));
|
||||
|
||||
app.use(healthRouter);
|
||||
app.use('/commands', createCommandsRouter());
|
||||
app.use('/commands', createCommandsRouter(options.commandHandlers));
|
||||
app.use('/commands', createMobileCommandsRouter(options.mobileCommandHandlers));
|
||||
|
||||
app.use(notFoundHandler);
|
||||
app.use(errorHandler);
|
||||
|
||||
14
backend/command-api/src/contracts/commands/attendance.js
Normal file
14
backend/command-api/src/contracts/commands/attendance.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const attendanceCommandSchema = z.object({
|
||||
assignmentId: z.string().uuid(),
|
||||
sourceType: z.enum(['NFC', 'GEO', 'QR', 'MANUAL', 'SYSTEM']),
|
||||
sourceReference: z.string().max(255).optional(),
|
||||
nfcTagUid: z.string().max(255).optional(),
|
||||
deviceId: z.string().max(255).optional(),
|
||||
latitude: z.number().min(-90).max(90).optional(),
|
||||
longitude: z.number().min(-180).max(180).optional(),
|
||||
accuracyMeters: z.number().int().nonnegative().optional(),
|
||||
capturedAt: z.string().datetime().optional(),
|
||||
rawPayload: z.record(z.any()).optional(),
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const favoriteStaffSchema = z.object({
|
||||
tenantId: z.string().uuid(),
|
||||
businessId: z.string().uuid(),
|
||||
staffId: z.string().uuid(),
|
||||
});
|
||||
360
backend/command-api/src/contracts/commands/mobile.js
Normal file
360
backend/command-api/src/contracts/commands/mobile.js
Normal file
@@ -0,0 +1,360 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const timeSlotSchema = z.object({
|
||||
start: z.string().min(1).max(20),
|
||||
end: z.string().min(1).max(20),
|
||||
});
|
||||
|
||||
const preferredLocationSchema = z.object({
|
||||
label: z.string().min(1).max(160),
|
||||
city: z.string().max(120).optional(),
|
||||
state: z.string().max(80).optional(),
|
||||
latitude: z.number().min(-90).max(90).optional(),
|
||||
longitude: z.number().min(-180).max(180).optional(),
|
||||
radiusMiles: z.number().nonnegative().optional(),
|
||||
});
|
||||
|
||||
const hhmmSchema = z.string().regex(/^\d{2}:\d{2}$/, 'Time must use HH:MM format');
|
||||
const isoDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must use YYYY-MM-DD format');
|
||||
const clockInModeSchema = z.enum(['NFC_REQUIRED', 'GEO_REQUIRED', 'EITHER']);
|
||||
const pushProviderSchema = z.enum(['FCM', 'APNS', 'WEB_PUSH']);
|
||||
const pushPlatformSchema = z.enum(['IOS', 'ANDROID', 'WEB']);
|
||||
|
||||
const shiftPositionSchema = z.object({
|
||||
roleId: z.string().uuid().optional(),
|
||||
roleCode: z.string().min(1).max(120).optional(),
|
||||
roleName: z.string().min(1).max(160).optional(),
|
||||
workerCount: z.number().int().positive().optional(),
|
||||
workersNeeded: z.number().int().positive().optional(),
|
||||
startTime: hhmmSchema,
|
||||
endTime: hhmmSchema,
|
||||
hourlyRateCents: z.number().int().nonnegative().optional(),
|
||||
payRateCents: z.number().int().nonnegative().optional(),
|
||||
billRateCents: z.number().int().nonnegative().optional(),
|
||||
lunchBreakMinutes: z.number().int().nonnegative().optional(),
|
||||
paidBreak: z.boolean().optional(),
|
||||
instantBook: z.boolean().optional(),
|
||||
metadata: z.record(z.any()).optional(),
|
||||
}).superRefine((value, ctx) => {
|
||||
if (!value.roleId && !value.roleCode && !value.roleName) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'roleId, roleCode, or roleName is required',
|
||||
path: ['roleId'],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const baseOrderCreateSchema = z.object({
|
||||
hubId: z.string().uuid(),
|
||||
vendorId: z.string().uuid().optional(),
|
||||
eventName: z.string().min(2).max(160),
|
||||
timezone: z.string().min(1).max(80).optional(),
|
||||
description: z.string().max(5000).optional(),
|
||||
notes: z.string().max(5000).optional(),
|
||||
serviceType: z.enum(['EVENT', 'CATERING', 'HOTEL', 'RESTAURANT', 'OTHER']).optional(),
|
||||
positions: z.array(shiftPositionSchema).min(1),
|
||||
metadata: z.record(z.any()).optional(),
|
||||
});
|
||||
|
||||
export const hubCreateSchema = z.object({
|
||||
name: z.string().min(1).max(160),
|
||||
fullAddress: z.string().max(300).optional(),
|
||||
placeId: z.string().max(255).optional(),
|
||||
latitude: z.number().min(-90).max(90).optional(),
|
||||
longitude: z.number().min(-180).max(180).optional(),
|
||||
street: z.string().max(160).optional(),
|
||||
city: z.string().max(120).optional(),
|
||||
state: z.string().max(80).optional(),
|
||||
country: z.string().max(80).optional(),
|
||||
zipCode: z.string().max(40).optional(),
|
||||
costCenterId: z.string().uuid().optional(),
|
||||
geofenceRadiusMeters: z.number().int().positive().optional(),
|
||||
nfcTagId: z.string().max(255).optional(),
|
||||
clockInMode: clockInModeSchema.optional(),
|
||||
allowClockInOverride: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const hubUpdateSchema = hubCreateSchema.extend({
|
||||
hubId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export const hubDeleteSchema = z.object({
|
||||
hubId: z.string().uuid(),
|
||||
reason: z.string().max(1000).optional(),
|
||||
});
|
||||
|
||||
export const hubAssignNfcSchema = z.object({
|
||||
hubId: z.string().uuid(),
|
||||
nfcTagId: z.string().min(1).max(255),
|
||||
});
|
||||
|
||||
export const hubAssignManagerSchema = z.object({
|
||||
hubId: z.string().uuid(),
|
||||
businessMembershipId: z.string().uuid().optional(),
|
||||
managerUserId: z.string().min(1).optional(),
|
||||
}).refine((value) => value.businessMembershipId || value.managerUserId, {
|
||||
message: 'businessMembershipId or managerUserId is required',
|
||||
});
|
||||
|
||||
export const invoiceApproveSchema = z.object({
|
||||
invoiceId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export const invoiceDisputeSchema = z.object({
|
||||
invoiceId: z.string().uuid(),
|
||||
reason: z.string().min(3).max(2000),
|
||||
});
|
||||
|
||||
export const coverageReviewSchema = z.object({
|
||||
staffId: z.string().uuid(),
|
||||
assignmentId: z.string().uuid().optional(),
|
||||
rating: z.number().int().min(1).max(5),
|
||||
markAsFavorite: z.boolean().optional(),
|
||||
issueFlags: z.array(z.string().min(1).max(80)).max(20).optional(),
|
||||
feedback: z.string().max(5000).optional(),
|
||||
});
|
||||
|
||||
export const cancelLateWorkerSchema = z.object({
|
||||
assignmentId: z.string().uuid(),
|
||||
reason: z.string().max(1000).optional(),
|
||||
});
|
||||
|
||||
export const clientOneTimeOrderSchema = baseOrderCreateSchema.extend({
|
||||
orderDate: isoDateSchema,
|
||||
});
|
||||
|
||||
export const clientRecurringOrderSchema = baseOrderCreateSchema.extend({
|
||||
startDate: isoDateSchema,
|
||||
endDate: isoDateSchema,
|
||||
recurrenceDays: z.array(z.number().int().min(0).max(6)).min(1),
|
||||
});
|
||||
|
||||
export const clientPermanentOrderSchema = baseOrderCreateSchema.extend({
|
||||
startDate: isoDateSchema,
|
||||
endDate: isoDateSchema.optional(),
|
||||
daysOfWeek: z.array(z.number().int().min(0).max(6)).min(1).optional(),
|
||||
horizonDays: z.number().int().min(7).max(180).optional(),
|
||||
});
|
||||
|
||||
export const clientOrderEditSchema = z.object({
|
||||
orderId: z.string().uuid(),
|
||||
orderType: z.enum(['ONE_TIME', 'RECURRING', 'PERMANENT']).optional(),
|
||||
hubId: z.string().uuid().optional(),
|
||||
vendorId: z.string().uuid().optional(),
|
||||
eventName: z.string().min(2).max(160).optional(),
|
||||
orderDate: isoDateSchema.optional(),
|
||||
startDate: isoDateSchema.optional(),
|
||||
endDate: isoDateSchema.optional(),
|
||||
recurrenceDays: z.array(z.number().int().min(0).max(6)).min(1).optional(),
|
||||
daysOfWeek: z.array(z.number().int().min(0).max(6)).min(1).optional(),
|
||||
timezone: z.string().min(1).max(80).optional(),
|
||||
description: z.string().max(5000).optional(),
|
||||
notes: z.string().max(5000).optional(),
|
||||
serviceType: z.enum(['EVENT', 'CATERING', 'HOTEL', 'RESTAURANT', 'OTHER']).optional(),
|
||||
positions: z.array(shiftPositionSchema).min(1).optional(),
|
||||
metadata: z.record(z.any()).optional(),
|
||||
}).superRefine((value, ctx) => {
|
||||
const keys = Object.keys(value).filter((key) => key !== 'orderId');
|
||||
if (keys.length === 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'At least one field must be provided to create an edited order copy',
|
||||
path: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const clientOrderCancelSchema = z.object({
|
||||
orderId: z.string().uuid(),
|
||||
reason: z.string().max(1000).optional(),
|
||||
metadata: z.record(z.any()).optional(),
|
||||
});
|
||||
|
||||
export const availabilityDayUpdateSchema = z.object({
|
||||
dayOfWeek: z.number().int().min(0).max(6),
|
||||
availabilityStatus: z.enum(['AVAILABLE', 'UNAVAILABLE', 'PARTIAL']),
|
||||
slots: z.array(timeSlotSchema).max(8).optional(),
|
||||
metadata: z.record(z.any()).optional(),
|
||||
});
|
||||
|
||||
export const availabilityQuickSetSchema = z.object({
|
||||
startDate: z.string().datetime().optional(),
|
||||
endDate: z.string().datetime().optional(),
|
||||
quickSetType: z.enum(['all', 'weekdays', 'weekends', 'clear']),
|
||||
slots: z.array(timeSlotSchema).max(8).optional(),
|
||||
});
|
||||
|
||||
export const shiftApplySchema = z.object({
|
||||
shiftId: z.string().uuid(),
|
||||
roleId: z.string().uuid().optional(),
|
||||
instantBook: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const shiftDecisionSchema = z.object({
|
||||
shiftId: z.string().uuid(),
|
||||
reason: z.string().max(1000).optional(),
|
||||
});
|
||||
|
||||
export const staffClockInSchema = z.object({
|
||||
assignmentId: z.string().uuid().optional(),
|
||||
shiftId: z.string().uuid().optional(),
|
||||
sourceType: z.enum(['NFC', 'GEO', 'QR', 'MANUAL', 'SYSTEM']).optional(),
|
||||
sourceReference: z.string().max(255).optional(),
|
||||
nfcTagId: z.string().max(255).optional(),
|
||||
deviceId: z.string().max(255).optional(),
|
||||
latitude: z.number().min(-90).max(90).optional(),
|
||||
longitude: z.number().min(-180).max(180).optional(),
|
||||
accuracyMeters: z.number().int().nonnegative().optional(),
|
||||
capturedAt: z.string().datetime().optional(),
|
||||
proofNonce: z.string().min(8).max(255).optional(),
|
||||
proofTimestamp: z.string().datetime().optional(),
|
||||
attestationProvider: z.enum(['PLAY_INTEGRITY', 'APP_ATTEST', 'DEVICE_CHECK']).optional(),
|
||||
attestationToken: z.string().min(16).max(20000).optional(),
|
||||
isMockLocation: z.boolean().optional(),
|
||||
notes: z.string().max(2000).optional(),
|
||||
overrideReason: z.string().max(2000).optional(),
|
||||
rawPayload: z.record(z.any()).optional(),
|
||||
}).refine((value) => value.assignmentId || value.shiftId, {
|
||||
message: 'assignmentId or shiftId is required',
|
||||
});
|
||||
|
||||
export const staffClockOutSchema = z.object({
|
||||
assignmentId: z.string().uuid().optional(),
|
||||
shiftId: z.string().uuid().optional(),
|
||||
applicationId: z.string().uuid().optional(),
|
||||
sourceType: z.enum(['NFC', 'GEO', 'QR', 'MANUAL', 'SYSTEM']).optional(),
|
||||
sourceReference: z.string().max(255).optional(),
|
||||
nfcTagId: z.string().max(255).optional(),
|
||||
deviceId: z.string().max(255).optional(),
|
||||
latitude: z.number().min(-90).max(90).optional(),
|
||||
longitude: z.number().min(-180).max(180).optional(),
|
||||
accuracyMeters: z.number().int().nonnegative().optional(),
|
||||
capturedAt: z.string().datetime().optional(),
|
||||
proofNonce: z.string().min(8).max(255).optional(),
|
||||
proofTimestamp: z.string().datetime().optional(),
|
||||
attestationProvider: z.enum(['PLAY_INTEGRITY', 'APP_ATTEST', 'DEVICE_CHECK']).optional(),
|
||||
attestationToken: z.string().min(16).max(20000).optional(),
|
||||
isMockLocation: z.boolean().optional(),
|
||||
notes: z.string().max(2000).optional(),
|
||||
overrideReason: z.string().max(2000).optional(),
|
||||
breakMinutes: z.number().int().nonnegative().optional(),
|
||||
rawPayload: z.record(z.any()).optional(),
|
||||
}).refine((value) => value.assignmentId || value.shiftId || value.applicationId, {
|
||||
message: 'assignmentId, shiftId, or applicationId is required',
|
||||
});
|
||||
|
||||
const locationPointSchema = z.object({
|
||||
capturedAt: z.string().datetime(),
|
||||
latitude: z.number().min(-90).max(90).optional(),
|
||||
longitude: z.number().min(-180).max(180).optional(),
|
||||
accuracyMeters: z.number().int().nonnegative().optional(),
|
||||
speedMps: z.number().nonnegative().optional(),
|
||||
isMocked: z.boolean().optional(),
|
||||
metadata: z.record(z.any()).optional(),
|
||||
});
|
||||
|
||||
export const staffLocationBatchSchema = z.object({
|
||||
assignmentId: z.string().uuid().optional(),
|
||||
shiftId: z.string().uuid().optional(),
|
||||
sourceType: z.enum(['NFC', 'GEO', 'QR', 'MANUAL', 'SYSTEM']).default('GEO'),
|
||||
deviceId: z.string().max(255).optional(),
|
||||
points: z.array(locationPointSchema).min(1).max(96),
|
||||
metadata: z.record(z.any()).optional(),
|
||||
}).refine((value) => value.assignmentId || value.shiftId, {
|
||||
message: 'assignmentId or shiftId is required',
|
||||
});
|
||||
|
||||
export const pushTokenRegisterSchema = z.object({
|
||||
provider: pushProviderSchema.default('FCM'),
|
||||
platform: pushPlatformSchema,
|
||||
pushToken: z.string().min(16).max(4096),
|
||||
deviceId: z.string().max(255).optional(),
|
||||
appVersion: z.string().max(80).optional(),
|
||||
appBuild: z.string().max(80).optional(),
|
||||
locale: z.string().max(32).optional(),
|
||||
timezone: z.string().max(64).optional(),
|
||||
notificationsEnabled: z.boolean().optional(),
|
||||
metadata: z.record(z.any()).optional(),
|
||||
});
|
||||
|
||||
export const pushTokenDeleteSchema = z.object({
|
||||
tokenId: z.string().uuid().optional(),
|
||||
pushToken: z.string().min(16).max(4096).optional(),
|
||||
reason: z.string().max(255).optional(),
|
||||
}).refine((value) => value.tokenId || value.pushToken, {
|
||||
message: 'tokenId or pushToken is required',
|
||||
});
|
||||
|
||||
export const staffProfileSetupSchema = z.object({
|
||||
fullName: z.string().min(2).max(160),
|
||||
bio: z.string().max(5000).optional(),
|
||||
email: z.string().email().optional(),
|
||||
phoneNumber: z.string().min(6).max(40),
|
||||
preferredLocations: z.array(preferredLocationSchema).max(20).optional(),
|
||||
maxDistanceMiles: z.number().nonnegative().max(500).optional(),
|
||||
industries: z.array(z.string().min(1).max(80)).max(30).optional(),
|
||||
skills: z.array(z.string().min(1).max(80)).max(50).optional(),
|
||||
primaryRole: z.string().max(120).optional(),
|
||||
tenantId: z.string().uuid().optional(),
|
||||
vendorId: z.string().uuid().optional(),
|
||||
});
|
||||
|
||||
export const personalInfoUpdateSchema = z.object({
|
||||
firstName: z.string().min(1).max(80).optional(),
|
||||
lastName: z.string().min(1).max(80).optional(),
|
||||
bio: z.string().max(5000).optional(),
|
||||
preferredLocations: z.array(preferredLocationSchema).max(20).optional(),
|
||||
maxDistanceMiles: z.number().nonnegative().max(500).optional(),
|
||||
email: z.string().email().optional(),
|
||||
phone: z.string().min(6).max(40).optional(),
|
||||
displayName: z.string().min(2).max(160).optional(),
|
||||
});
|
||||
|
||||
export const profileExperienceSchema = z.object({
|
||||
industries: z.array(z.string().min(1).max(80)).max(30).optional(),
|
||||
skills: z.array(z.string().min(1).max(80)).max(50).optional(),
|
||||
primaryRole: z.string().max(120).optional(),
|
||||
});
|
||||
|
||||
export const preferredLocationsUpdateSchema = z.object({
|
||||
preferredLocations: z.array(preferredLocationSchema).max(20),
|
||||
maxDistanceMiles: z.number().nonnegative().max(500).optional(),
|
||||
});
|
||||
|
||||
export const emergencyContactCreateSchema = z.object({
|
||||
fullName: z.string().min(2).max(160),
|
||||
phone: z.string().min(6).max(40),
|
||||
relationshipType: z.string().min(1).max(120),
|
||||
isPrimary: z.boolean().optional(),
|
||||
metadata: z.record(z.any()).optional(),
|
||||
});
|
||||
|
||||
export const emergencyContactUpdateSchema = emergencyContactCreateSchema.partial().extend({
|
||||
contactId: z.string().uuid(),
|
||||
});
|
||||
|
||||
const taxFormFieldsSchema = z.record(z.any());
|
||||
|
||||
export const taxFormDraftSchema = z.object({
|
||||
formType: z.enum(['I9', 'W4']),
|
||||
fields: taxFormFieldsSchema,
|
||||
});
|
||||
|
||||
export const taxFormSubmitSchema = z.object({
|
||||
formType: z.enum(['I9', 'W4']),
|
||||
fields: taxFormFieldsSchema,
|
||||
});
|
||||
|
||||
export const bankAccountCreateSchema = z.object({
|
||||
bankName: z.string().min(2).max(160),
|
||||
accountNumber: z.string().min(4).max(34),
|
||||
routingNumber: z.string().min(4).max(20),
|
||||
accountType: z.string()
|
||||
.transform((value) => value.trim().toUpperCase())
|
||||
.pipe(z.enum(['CHECKING', 'SAVINGS'])),
|
||||
});
|
||||
|
||||
export const privacyUpdateSchema = z.object({
|
||||
profileVisible: z.boolean(),
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const orderCancelSchema = z.object({
|
||||
orderId: z.string().uuid(),
|
||||
tenantId: z.string().uuid(),
|
||||
reason: z.string().max(1000).optional(),
|
||||
metadata: z.record(z.any()).optional(),
|
||||
});
|
||||
58
backend/command-api/src/contracts/commands/order-create.js
Normal file
58
backend/command-api/src/contracts/commands/order-create.js
Normal file
@@ -0,0 +1,58 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const roleSchema = z.object({
|
||||
roleId: z.string().uuid().optional(),
|
||||
roleCode: z.string().min(1).max(100),
|
||||
roleName: z.string().min(1).max(120),
|
||||
workersNeeded: z.number().int().positive(),
|
||||
payRateCents: z.number().int().nonnegative().optional(),
|
||||
billRateCents: z.number().int().nonnegative().optional(),
|
||||
metadata: z.record(z.any()).optional(),
|
||||
});
|
||||
|
||||
const shiftSchema = z.object({
|
||||
shiftCode: z.string().min(1).max(80),
|
||||
title: z.string().min(1).max(160),
|
||||
status: z.enum([
|
||||
'DRAFT',
|
||||
'OPEN',
|
||||
'PENDING_CONFIRMATION',
|
||||
'ASSIGNED',
|
||||
'ACTIVE',
|
||||
'COMPLETED',
|
||||
'CANCELLED',
|
||||
]).optional(),
|
||||
startsAt: z.string().datetime(),
|
||||
endsAt: z.string().datetime(),
|
||||
timezone: z.string().min(1).max(80).optional(),
|
||||
clockPointId: z.string().uuid().optional(),
|
||||
locationName: z.string().max(160).optional(),
|
||||
locationAddress: z.string().max(300).optional(),
|
||||
latitude: z.number().min(-90).max(90).optional(),
|
||||
longitude: z.number().min(-180).max(180).optional(),
|
||||
geofenceRadiusMeters: z.number().int().positive().optional(),
|
||||
requiredWorkers: z.number().int().positive(),
|
||||
notes: z.string().max(5000).optional(),
|
||||
metadata: z.record(z.any()).optional(),
|
||||
roles: z.array(roleSchema).min(1),
|
||||
});
|
||||
|
||||
export const orderCreateSchema = z.object({
|
||||
tenantId: z.string().uuid(),
|
||||
businessId: z.string().uuid(),
|
||||
vendorId: z.string().uuid().optional(),
|
||||
orderNumber: z.string().min(1).max(80),
|
||||
title: z.string().min(1).max(160),
|
||||
description: z.string().max(5000).optional(),
|
||||
status: z.enum(['DRAFT', 'OPEN', 'FILLED', 'ACTIVE', 'COMPLETED', 'CANCELLED']).optional(),
|
||||
serviceType: z.enum(['EVENT', 'CATERING', 'HOTEL', 'RESTAURANT', 'OTHER']).optional(),
|
||||
startsAt: z.string().datetime().optional(),
|
||||
endsAt: z.string().datetime().optional(),
|
||||
locationName: z.string().max(160).optional(),
|
||||
locationAddress: z.string().max(300).optional(),
|
||||
latitude: z.number().min(-90).max(90).optional(),
|
||||
longitude: z.number().min(-180).max(180).optional(),
|
||||
notes: z.string().max(5000).optional(),
|
||||
metadata: z.record(z.any()).optional(),
|
||||
shifts: z.array(shiftSchema).min(1),
|
||||
});
|
||||
35
backend/command-api/src/contracts/commands/order-update.js
Normal file
35
backend/command-api/src/contracts/commands/order-update.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const nullableString = (max) => z.union([z.string().max(max), z.null()]);
|
||||
const nullableDateTime = z.union([z.string().datetime(), z.null()]);
|
||||
const nullableUuid = z.union([z.string().uuid(), z.null()]);
|
||||
|
||||
const orderUpdateShape = {
|
||||
orderId: z.string().uuid(),
|
||||
tenantId: z.string().uuid(),
|
||||
vendorId: nullableUuid.optional(),
|
||||
title: nullableString(160).optional(),
|
||||
description: nullableString(5000).optional(),
|
||||
status: z.enum(['DRAFT', 'OPEN', 'FILLED', 'ACTIVE', 'COMPLETED']).optional(),
|
||||
serviceType: z.enum(['EVENT', 'CATERING', 'HOTEL', 'RESTAURANT', 'OTHER']).optional(),
|
||||
startsAt: nullableDateTime.optional(),
|
||||
endsAt: nullableDateTime.optional(),
|
||||
locationName: nullableString(160).optional(),
|
||||
locationAddress: nullableString(300).optional(),
|
||||
latitude: z.union([z.number().min(-90).max(90), z.null()]).optional(),
|
||||
longitude: z.union([z.number().min(-180).max(180), z.null()]).optional(),
|
||||
notes: nullableString(5000).optional(),
|
||||
metadata: z.record(z.any()).optional(),
|
||||
};
|
||||
|
||||
export const orderUpdateSchema = z.object(orderUpdateShape).superRefine((value, ctx) => {
|
||||
const mutableKeys = Object.keys(orderUpdateShape).filter((key) => !['orderId', 'tenantId'].includes(key));
|
||||
const hasMutableField = mutableKeys.some((key) => Object.prototype.hasOwnProperty.call(value, key));
|
||||
if (!hasMutableField) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'At least one mutable order field must be provided',
|
||||
path: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const shiftAcceptSchema = z.object({
|
||||
shiftId: z.string().uuid().optional(),
|
||||
shiftRoleId: z.string().uuid(),
|
||||
workforceId: z.string().uuid(),
|
||||
metadata: z.record(z.any()).optional(),
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const shiftAssignStaffSchema = z.object({
|
||||
shiftId: z.string().uuid(),
|
||||
tenantId: z.string().uuid(),
|
||||
shiftRoleId: z.string().uuid(),
|
||||
workforceId: z.string().uuid(),
|
||||
applicationId: z.string().uuid().optional(),
|
||||
metadata: z.record(z.any()).optional(),
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const shiftStatusChangeSchema = z.object({
|
||||
shiftId: z.string().uuid(),
|
||||
tenantId: z.string().uuid(),
|
||||
status: z.enum([
|
||||
'DRAFT',
|
||||
'OPEN',
|
||||
'PENDING_CONFIRMATION',
|
||||
'ASSIGNED',
|
||||
'ACTIVE',
|
||||
'COMPLETED',
|
||||
'CANCELLED',
|
||||
]),
|
||||
reason: z.string().max(1000).optional(),
|
||||
metadata: z.record(z.any()).optional(),
|
||||
});
|
||||
11
backend/command-api/src/contracts/commands/staff-review.js
Normal file
11
backend/command-api/src/contracts/commands/staff-review.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const staffReviewSchema = z.object({
|
||||
tenantId: z.string().uuid(),
|
||||
businessId: z.string().uuid(),
|
||||
staffId: z.string().uuid(),
|
||||
assignmentId: z.string().uuid(),
|
||||
rating: z.number().int().min(1).max(5),
|
||||
reviewText: z.string().max(5000).optional(),
|
||||
tags: z.array(z.string().min(1).max(80)).max(20).optional(),
|
||||
});
|
||||
@@ -3,10 +3,45 @@ import { AppError } from '../lib/errors.js';
|
||||
import { requireAuth, requirePolicy } from '../middleware/auth.js';
|
||||
import { requireIdempotencyKey } from '../middleware/idempotency.js';
|
||||
import { buildIdempotencyKey, readIdempotentResult, writeIdempotentResult } from '../services/idempotency-store.js';
|
||||
import { commandBaseSchema } from '../contracts/commands/command-base.js';
|
||||
import {
|
||||
addFavoriteStaff,
|
||||
clockIn,
|
||||
clockOut,
|
||||
createOrder,
|
||||
createStaffReview,
|
||||
updateOrder,
|
||||
cancelOrder,
|
||||
changeShiftStatus,
|
||||
assignStaffToShift,
|
||||
removeFavoriteStaff,
|
||||
acceptShift,
|
||||
} from '../services/command-service.js';
|
||||
import { attendanceCommandSchema } from '../contracts/commands/attendance.js';
|
||||
import { favoriteStaffSchema } from '../contracts/commands/favorite-staff.js';
|
||||
import { orderCancelSchema } from '../contracts/commands/order-cancel.js';
|
||||
import { orderCreateSchema } from '../contracts/commands/order-create.js';
|
||||
import { orderUpdateSchema } from '../contracts/commands/order-update.js';
|
||||
import { shiftAssignStaffSchema } from '../contracts/commands/shift-assign-staff.js';
|
||||
import { shiftAcceptSchema } from '../contracts/commands/shift-accept.js';
|
||||
import { shiftStatusChangeSchema } from '../contracts/commands/shift-status-change.js';
|
||||
import { staffReviewSchema } from '../contracts/commands/staff-review.js';
|
||||
|
||||
function parseBody(body) {
|
||||
const parsed = commandBaseSchema.safeParse(body || {});
|
||||
const defaultHandlers = {
|
||||
addFavoriteStaff,
|
||||
assignStaffToShift,
|
||||
cancelOrder,
|
||||
changeShiftStatus,
|
||||
clockIn,
|
||||
clockOut,
|
||||
createOrder,
|
||||
createStaffReview,
|
||||
removeFavoriteStaff,
|
||||
acceptShift,
|
||||
updateOrder,
|
||||
};
|
||||
|
||||
function parseBody(schema, body) {
|
||||
const parsed = schema.safeParse(body || {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError('VALIDATION_ERROR', 'Invalid command payload', 400, {
|
||||
issues: parsed.error.issues,
|
||||
@@ -15,50 +50,37 @@ function parseBody(body) {
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function createCommandResponse(route, requestId, idempotencyKey) {
|
||||
return {
|
||||
accepted: true,
|
||||
async function runIdempotentCommand(req, res, work) {
|
||||
const route = `${req.baseUrl}${req.route.path}`;
|
||||
const compositeKey = buildIdempotencyKey({
|
||||
userId: req.actor.uid,
|
||||
route,
|
||||
commandId: `${route}:${Date.now()}`,
|
||||
idempotencyKey,
|
||||
requestId,
|
||||
idempotencyKey: req.idempotencyKey,
|
||||
});
|
||||
|
||||
const existing = await readIdempotentResult(compositeKey);
|
||||
if (existing) {
|
||||
return res.status(existing.statusCode).json(existing.payload);
|
||||
}
|
||||
|
||||
const payload = await work();
|
||||
const responsePayload = {
|
||||
...payload,
|
||||
idempotencyKey: req.idempotencyKey,
|
||||
requestId: req.requestId,
|
||||
};
|
||||
const persisted = await writeIdempotentResult({
|
||||
compositeKey,
|
||||
userId: req.actor.uid,
|
||||
route,
|
||||
idempotencyKey: req.idempotencyKey,
|
||||
payload: responsePayload,
|
||||
statusCode: 200,
|
||||
});
|
||||
return res.status(persisted.statusCode).json(persisted.payload);
|
||||
}
|
||||
|
||||
function buildCommandHandler(policyAction, policyResource) {
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
parseBody(req.body);
|
||||
|
||||
const route = `${req.baseUrl}${req.route.path}`;
|
||||
const compositeKey = buildIdempotencyKey({
|
||||
userId: req.actor.uid,
|
||||
route,
|
||||
idempotencyKey: req.idempotencyKey,
|
||||
});
|
||||
|
||||
const existing = await readIdempotentResult(compositeKey);
|
||||
if (existing) {
|
||||
return res.status(existing.statusCode).json(existing.payload);
|
||||
}
|
||||
|
||||
const payload = createCommandResponse(route, req.requestId, req.idempotencyKey);
|
||||
const persisted = await writeIdempotentResult({
|
||||
compositeKey,
|
||||
userId: req.actor.uid,
|
||||
route,
|
||||
idempotencyKey: req.idempotencyKey,
|
||||
payload,
|
||||
statusCode: 200,
|
||||
});
|
||||
return res.status(persisted.statusCode).json(persisted.payload);
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function createCommandsRouter() {
|
||||
export function createCommandsRouter(handlers = defaultHandlers) {
|
||||
const router = Router();
|
||||
|
||||
router.post(
|
||||
@@ -66,7 +88,14 @@ export function createCommandsRouter() {
|
||||
requireAuth,
|
||||
requireIdempotencyKey,
|
||||
requirePolicy('orders.create', 'order'),
|
||||
buildCommandHandler('orders.create', 'order')
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const payload = parseBody(orderCreateSchema, req.body);
|
||||
return await runIdempotentCommand(req, res, () => handlers.createOrder(req.actor, payload));
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
@@ -74,7 +103,17 @@ export function createCommandsRouter() {
|
||||
requireAuth,
|
||||
requireIdempotencyKey,
|
||||
requirePolicy('orders.update', 'order'),
|
||||
buildCommandHandler('orders.update', 'order')
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const payload = parseBody(orderUpdateSchema, {
|
||||
...req.body,
|
||||
orderId: req.params.orderId,
|
||||
});
|
||||
return await runIdempotentCommand(req, res, () => handlers.updateOrder(req.actor, payload));
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
@@ -82,7 +121,17 @@ export function createCommandsRouter() {
|
||||
requireAuth,
|
||||
requireIdempotencyKey,
|
||||
requirePolicy('orders.cancel', 'order'),
|
||||
buildCommandHandler('orders.cancel', 'order')
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const payload = parseBody(orderCancelSchema, {
|
||||
...req.body,
|
||||
orderId: req.params.orderId,
|
||||
});
|
||||
return await runIdempotentCommand(req, res, () => handlers.cancelOrder(req.actor, payload));
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
@@ -90,7 +139,17 @@ export function createCommandsRouter() {
|
||||
requireAuth,
|
||||
requireIdempotencyKey,
|
||||
requirePolicy('shifts.change-status', 'shift'),
|
||||
buildCommandHandler('shifts.change-status', 'shift')
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const payload = parseBody(shiftStatusChangeSchema, {
|
||||
...req.body,
|
||||
shiftId: req.params.shiftId,
|
||||
});
|
||||
return await runIdempotentCommand(req, res, () => handlers.changeShiftStatus(req.actor, payload));
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
@@ -98,7 +157,17 @@ export function createCommandsRouter() {
|
||||
requireAuth,
|
||||
requireIdempotencyKey,
|
||||
requirePolicy('shifts.assign-staff', 'shift'),
|
||||
buildCommandHandler('shifts.assign-staff', 'shift')
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const payload = parseBody(shiftAssignStaffSchema, {
|
||||
...req.body,
|
||||
shiftId: req.params.shiftId,
|
||||
});
|
||||
return await runIdempotentCommand(req, res, () => handlers.assignStaffToShift(req.actor, payload));
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
@@ -106,7 +175,102 @@ export function createCommandsRouter() {
|
||||
requireAuth,
|
||||
requireIdempotencyKey,
|
||||
requirePolicy('shifts.accept', 'shift'),
|
||||
buildCommandHandler('shifts.accept', 'shift')
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const payload = parseBody(shiftAcceptSchema, {
|
||||
...req.body,
|
||||
shiftId: req.params.shiftId,
|
||||
});
|
||||
return await runIdempotentCommand(req, res, () => handlers.acceptShift(req.actor, payload));
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/attendance/clock-in',
|
||||
requireAuth,
|
||||
requireIdempotencyKey,
|
||||
requirePolicy('attendance.clock-in', 'attendance'),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const payload = parseBody(attendanceCommandSchema, req.body);
|
||||
return await runIdempotentCommand(req, res, () => handlers.clockIn(req.actor, payload));
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/attendance/clock-out',
|
||||
requireAuth,
|
||||
requireIdempotencyKey,
|
||||
requirePolicy('attendance.clock-out', 'attendance'),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const payload = parseBody(attendanceCommandSchema, req.body);
|
||||
return await runIdempotentCommand(req, res, () => handlers.clockOut(req.actor, payload));
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/businesses/:businessId/favorite-staff',
|
||||
requireAuth,
|
||||
requireIdempotencyKey,
|
||||
requirePolicy('business.favorite-staff', 'staff'),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const payload = parseBody(favoriteStaffSchema, {
|
||||
...req.body,
|
||||
businessId: req.params.businessId,
|
||||
});
|
||||
return await runIdempotentCommand(req, res, () => handlers.addFavoriteStaff(req.actor, payload));
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/businesses/:businessId/favorite-staff/:staffId',
|
||||
requireAuth,
|
||||
requireIdempotencyKey,
|
||||
requirePolicy('business.unfavorite-staff', 'staff'),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const payload = parseBody(favoriteStaffSchema, {
|
||||
...req.body,
|
||||
businessId: req.params.businessId,
|
||||
staffId: req.params.staffId,
|
||||
});
|
||||
return await runIdempotentCommand(req, res, () => handlers.removeFavoriteStaff(req.actor, payload));
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/assignments/:assignmentId/reviews',
|
||||
requireAuth,
|
||||
requireIdempotencyKey,
|
||||
requirePolicy('assignments.review-staff', 'assignment'),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const payload = parseBody(staffReviewSchema, {
|
||||
...req.body,
|
||||
assignmentId: req.params.assignmentId,
|
||||
});
|
||||
return await runIdempotentCommand(req, res, () => handlers.createStaffReview(req.actor, payload));
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return router;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Router } from 'express';
|
||||
import { checkDatabaseHealth, isDatabaseConfigured } from '../services/db.js';
|
||||
|
||||
export const healthRouter = Router();
|
||||
|
||||
@@ -13,3 +14,32 @@ function healthHandler(req, res) {
|
||||
|
||||
healthRouter.get('/health', healthHandler);
|
||||
healthRouter.get('/healthz', healthHandler);
|
||||
|
||||
healthRouter.get('/readyz', async (req, res) => {
|
||||
if (!isDatabaseConfigured()) {
|
||||
return res.status(503).json({
|
||||
ok: false,
|
||||
service: 'krow-command-api',
|
||||
status: 'DATABASE_NOT_CONFIGURED',
|
||||
requestId: req.requestId,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const ok = await checkDatabaseHealth();
|
||||
return res.status(ok ? 200 : 503).json({
|
||||
ok,
|
||||
service: 'krow-command-api',
|
||||
status: ok ? 'READY' : 'DATABASE_UNAVAILABLE',
|
||||
requestId: req.requestId,
|
||||
});
|
||||
} catch (error) {
|
||||
return res.status(503).json({
|
||||
ok: false,
|
||||
service: 'krow-command-api',
|
||||
status: 'DATABASE_UNAVAILABLE',
|
||||
details: { message: error.message },
|
||||
requestId: req.requestId,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
472
backend/command-api/src/routes/mobile.js
Normal file
472
backend/command-api/src/routes/mobile.js
Normal file
@@ -0,0 +1,472 @@
|
||||
import { Router } from 'express';
|
||||
import { AppError } from '../lib/errors.js';
|
||||
import { requireAuth, requirePolicy } from '../middleware/auth.js';
|
||||
import { requireIdempotencyKey } from '../middleware/idempotency.js';
|
||||
import { buildIdempotencyKey, readIdempotentResult, writeIdempotentResult } from '../services/idempotency-store.js';
|
||||
import {
|
||||
addStaffBankAccount,
|
||||
approveInvoice,
|
||||
applyForShift,
|
||||
assignHubManager,
|
||||
assignHubNfc,
|
||||
cancelLateWorker,
|
||||
cancelClientOrder,
|
||||
createEmergencyContact,
|
||||
createClientOneTimeOrder,
|
||||
createClientPermanentOrder,
|
||||
createClientRecurringOrder,
|
||||
createEditedOrderCopy,
|
||||
createHub,
|
||||
declinePendingShift,
|
||||
disputeInvoice,
|
||||
quickSetStaffAvailability,
|
||||
rateWorkerFromCoverage,
|
||||
registerClientPushToken,
|
||||
registerStaffPushToken,
|
||||
requestShiftSwap,
|
||||
saveTaxFormDraft,
|
||||
setupStaffProfile,
|
||||
staffClockIn,
|
||||
staffClockOut,
|
||||
submitLocationStreamBatch,
|
||||
submitTaxForm,
|
||||
unregisterClientPushToken,
|
||||
unregisterStaffPushToken,
|
||||
updateEmergencyContact,
|
||||
updateHub,
|
||||
updatePersonalInfo,
|
||||
updatePreferredLocations,
|
||||
updatePrivacyVisibility,
|
||||
updateProfileExperience,
|
||||
updateStaffAvailabilityDay,
|
||||
deleteHub,
|
||||
acceptPendingShift,
|
||||
} from '../services/mobile-command-service.js';
|
||||
import {
|
||||
availabilityDayUpdateSchema,
|
||||
availabilityQuickSetSchema,
|
||||
bankAccountCreateSchema,
|
||||
cancelLateWorkerSchema,
|
||||
clientOneTimeOrderSchema,
|
||||
clientOrderCancelSchema,
|
||||
clientOrderEditSchema,
|
||||
clientPermanentOrderSchema,
|
||||
clientRecurringOrderSchema,
|
||||
coverageReviewSchema,
|
||||
emergencyContactCreateSchema,
|
||||
emergencyContactUpdateSchema,
|
||||
hubAssignManagerSchema,
|
||||
hubAssignNfcSchema,
|
||||
hubCreateSchema,
|
||||
hubDeleteSchema,
|
||||
hubUpdateSchema,
|
||||
invoiceApproveSchema,
|
||||
invoiceDisputeSchema,
|
||||
personalInfoUpdateSchema,
|
||||
preferredLocationsUpdateSchema,
|
||||
privacyUpdateSchema,
|
||||
profileExperienceSchema,
|
||||
pushTokenDeleteSchema,
|
||||
pushTokenRegisterSchema,
|
||||
shiftApplySchema,
|
||||
shiftDecisionSchema,
|
||||
staffClockInSchema,
|
||||
staffClockOutSchema,
|
||||
staffLocationBatchSchema,
|
||||
staffProfileSetupSchema,
|
||||
taxFormDraftSchema,
|
||||
taxFormSubmitSchema,
|
||||
} from '../contracts/commands/mobile.js';
|
||||
|
||||
const defaultHandlers = {
|
||||
acceptPendingShift,
|
||||
addStaffBankAccount,
|
||||
approveInvoice,
|
||||
applyForShift,
|
||||
assignHubManager,
|
||||
assignHubNfc,
|
||||
cancelLateWorker,
|
||||
cancelClientOrder,
|
||||
createEmergencyContact,
|
||||
createClientOneTimeOrder,
|
||||
createClientPermanentOrder,
|
||||
createClientRecurringOrder,
|
||||
createEditedOrderCopy,
|
||||
createHub,
|
||||
declinePendingShift,
|
||||
disputeInvoice,
|
||||
quickSetStaffAvailability,
|
||||
rateWorkerFromCoverage,
|
||||
registerClientPushToken,
|
||||
registerStaffPushToken,
|
||||
requestShiftSwap,
|
||||
saveTaxFormDraft,
|
||||
setupStaffProfile,
|
||||
staffClockIn,
|
||||
staffClockOut,
|
||||
submitLocationStreamBatch,
|
||||
submitTaxForm,
|
||||
unregisterClientPushToken,
|
||||
unregisterStaffPushToken,
|
||||
updateEmergencyContact,
|
||||
updateHub,
|
||||
updatePersonalInfo,
|
||||
updatePreferredLocations,
|
||||
updatePrivacyVisibility,
|
||||
updateProfileExperience,
|
||||
updateStaffAvailabilityDay,
|
||||
deleteHub,
|
||||
};
|
||||
|
||||
function parseBody(schema, body) {
|
||||
const parsed = schema.safeParse(body || {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError('VALIDATION_ERROR', 'Invalid request payload', 400, {
|
||||
issues: parsed.error.issues,
|
||||
});
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
async function runIdempotentCommand(req, res, work) {
|
||||
const route = `${req.baseUrl}${req.route.path}`;
|
||||
const compositeKey = buildIdempotencyKey({
|
||||
userId: req.actor.uid,
|
||||
route,
|
||||
idempotencyKey: req.idempotencyKey,
|
||||
});
|
||||
|
||||
const existing = await readIdempotentResult(compositeKey);
|
||||
if (existing) {
|
||||
return res.status(existing.statusCode).json(existing.payload);
|
||||
}
|
||||
|
||||
const payload = await work();
|
||||
const responsePayload = {
|
||||
...payload,
|
||||
idempotencyKey: req.idempotencyKey,
|
||||
requestId: req.requestId,
|
||||
};
|
||||
const persisted = await writeIdempotentResult({
|
||||
compositeKey,
|
||||
userId: req.actor.uid,
|
||||
route,
|
||||
idempotencyKey: req.idempotencyKey,
|
||||
payload: responsePayload,
|
||||
statusCode: 200,
|
||||
});
|
||||
return res.status(persisted.statusCode).json(persisted.payload);
|
||||
}
|
||||
|
||||
function mobileCommand(route, { schema, policyAction, resource, handler, paramShape }) {
|
||||
return [
|
||||
route,
|
||||
requireAuth,
|
||||
requireIdempotencyKey,
|
||||
requirePolicy(policyAction, resource),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const body = typeof paramShape === 'function'
|
||||
? paramShape(req)
|
||||
: req.body;
|
||||
const payload = parseBody(schema, body);
|
||||
return await runIdempotentCommand(req, res, () => handler(req.actor, payload));
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function createMobileCommandsRouter(handlers = defaultHandlers) {
|
||||
const router = Router();
|
||||
|
||||
router.post(...mobileCommand('/client/orders/one-time', {
|
||||
schema: clientOneTimeOrderSchema,
|
||||
policyAction: 'orders.create',
|
||||
resource: 'order',
|
||||
handler: handlers.createClientOneTimeOrder,
|
||||
}));
|
||||
|
||||
router.post(...mobileCommand('/client/orders/recurring', {
|
||||
schema: clientRecurringOrderSchema,
|
||||
policyAction: 'orders.create',
|
||||
resource: 'order',
|
||||
handler: handlers.createClientRecurringOrder,
|
||||
}));
|
||||
|
||||
router.post(...mobileCommand('/client/orders/permanent', {
|
||||
schema: clientPermanentOrderSchema,
|
||||
policyAction: 'orders.create',
|
||||
resource: 'order',
|
||||
handler: handlers.createClientPermanentOrder,
|
||||
}));
|
||||
|
||||
router.post(...mobileCommand('/client/orders/:orderId/edit', {
|
||||
schema: clientOrderEditSchema,
|
||||
policyAction: 'orders.update',
|
||||
resource: 'order',
|
||||
handler: handlers.createEditedOrderCopy,
|
||||
paramShape: (req) => ({ ...req.body, orderId: req.params.orderId }),
|
||||
}));
|
||||
|
||||
router.post(...mobileCommand('/client/orders/:orderId/cancel', {
|
||||
schema: clientOrderCancelSchema,
|
||||
policyAction: 'orders.cancel',
|
||||
resource: 'order',
|
||||
handler: handlers.cancelClientOrder,
|
||||
paramShape: (req) => ({ ...req.body, orderId: req.params.orderId }),
|
||||
}));
|
||||
|
||||
router.post(...mobileCommand('/client/hubs', {
|
||||
schema: hubCreateSchema,
|
||||
policyAction: 'client.hubs.create',
|
||||
resource: 'hub',
|
||||
handler: handlers.createHub,
|
||||
}));
|
||||
|
||||
router.put(...mobileCommand('/client/hubs/:hubId', {
|
||||
schema: hubUpdateSchema,
|
||||
policyAction: 'client.hubs.update',
|
||||
resource: 'hub',
|
||||
handler: handlers.updateHub,
|
||||
paramShape: (req) => ({ ...req.body, hubId: req.params.hubId }),
|
||||
}));
|
||||
|
||||
router.delete(...mobileCommand('/client/hubs/:hubId', {
|
||||
schema: hubDeleteSchema,
|
||||
policyAction: 'client.hubs.delete',
|
||||
resource: 'hub',
|
||||
handler: handlers.deleteHub,
|
||||
paramShape: (req) => ({ ...req.body, hubId: req.params.hubId }),
|
||||
}));
|
||||
|
||||
router.post(...mobileCommand('/client/hubs/:hubId/assign-nfc', {
|
||||
schema: hubAssignNfcSchema,
|
||||
policyAction: 'client.hubs.update',
|
||||
resource: 'hub',
|
||||
handler: handlers.assignHubNfc,
|
||||
paramShape: (req) => ({ ...req.body, hubId: req.params.hubId }),
|
||||
}));
|
||||
|
||||
router.post(...mobileCommand('/client/hubs/:hubId/managers', {
|
||||
schema: hubAssignManagerSchema,
|
||||
policyAction: 'client.hubs.update',
|
||||
resource: 'hub',
|
||||
handler: handlers.assignHubManager,
|
||||
paramShape: (req) => ({ ...req.body, hubId: req.params.hubId }),
|
||||
}));
|
||||
|
||||
router.post(...mobileCommand('/client/billing/invoices/:invoiceId/approve', {
|
||||
schema: invoiceApproveSchema,
|
||||
policyAction: 'client.billing.write',
|
||||
resource: 'invoice',
|
||||
handler: handlers.approveInvoice,
|
||||
paramShape: (req) => ({ invoiceId: req.params.invoiceId }),
|
||||
}));
|
||||
|
||||
router.post(...mobileCommand('/client/billing/invoices/:invoiceId/dispute', {
|
||||
schema: invoiceDisputeSchema,
|
||||
policyAction: 'client.billing.write',
|
||||
resource: 'invoice',
|
||||
handler: handlers.disputeInvoice,
|
||||
paramShape: (req) => ({ ...req.body, invoiceId: req.params.invoiceId }),
|
||||
}));
|
||||
|
||||
router.post(...mobileCommand('/client/coverage/reviews', {
|
||||
schema: coverageReviewSchema,
|
||||
policyAction: 'client.coverage.write',
|
||||
resource: 'staff_review',
|
||||
handler: handlers.rateWorkerFromCoverage,
|
||||
}));
|
||||
|
||||
router.post(...mobileCommand('/client/coverage/late-workers/:assignmentId/cancel', {
|
||||
schema: cancelLateWorkerSchema,
|
||||
policyAction: 'client.coverage.write',
|
||||
resource: 'assignment',
|
||||
handler: handlers.cancelLateWorker,
|
||||
paramShape: (req) => ({ ...req.body, assignmentId: req.params.assignmentId }),
|
||||
}));
|
||||
|
||||
router.post(...mobileCommand('/staff/profile/setup', {
|
||||
schema: staffProfileSetupSchema,
|
||||
policyAction: 'staff.profile.write',
|
||||
resource: 'staff',
|
||||
handler: handlers.setupStaffProfile,
|
||||
}));
|
||||
|
||||
router.post(...mobileCommand('/client/devices/push-tokens', {
|
||||
schema: pushTokenRegisterSchema,
|
||||
policyAction: 'notifications.device.write',
|
||||
resource: 'device_push_token',
|
||||
handler: handlers.registerClientPushToken,
|
||||
}));
|
||||
|
||||
router.delete(...mobileCommand('/client/devices/push-tokens', {
|
||||
schema: pushTokenDeleteSchema,
|
||||
policyAction: 'notifications.device.write',
|
||||
resource: 'device_push_token',
|
||||
handler: handlers.unregisterClientPushToken,
|
||||
paramShape: (req) => ({
|
||||
...req.body,
|
||||
tokenId: req.body?.tokenId || req.query.tokenId,
|
||||
pushToken: req.body?.pushToken || req.query.pushToken,
|
||||
reason: req.body?.reason || req.query.reason,
|
||||
}),
|
||||
}));
|
||||
|
||||
router.post(...mobileCommand('/staff/clock-in', {
|
||||
schema: staffClockInSchema,
|
||||
policyAction: 'attendance.clock-in',
|
||||
resource: 'attendance',
|
||||
handler: handlers.staffClockIn,
|
||||
}));
|
||||
|
||||
router.post(...mobileCommand('/staff/clock-out', {
|
||||
schema: staffClockOutSchema,
|
||||
policyAction: 'attendance.clock-out',
|
||||
resource: 'attendance',
|
||||
handler: handlers.staffClockOut,
|
||||
}));
|
||||
|
||||
router.post(...mobileCommand('/staff/location-streams', {
|
||||
schema: staffLocationBatchSchema,
|
||||
policyAction: 'attendance.location-stream.write',
|
||||
resource: 'attendance',
|
||||
handler: handlers.submitLocationStreamBatch,
|
||||
}));
|
||||
|
||||
router.post(...mobileCommand('/staff/devices/push-tokens', {
|
||||
schema: pushTokenRegisterSchema,
|
||||
policyAction: 'notifications.device.write',
|
||||
resource: 'device_push_token',
|
||||
handler: handlers.registerStaffPushToken,
|
||||
}));
|
||||
|
||||
router.delete(...mobileCommand('/staff/devices/push-tokens', {
|
||||
schema: pushTokenDeleteSchema,
|
||||
policyAction: 'notifications.device.write',
|
||||
resource: 'device_push_token',
|
||||
handler: handlers.unregisterStaffPushToken,
|
||||
paramShape: (req) => ({
|
||||
...req.body,
|
||||
tokenId: req.body?.tokenId || req.query.tokenId,
|
||||
pushToken: req.body?.pushToken || req.query.pushToken,
|
||||
reason: req.body?.reason || req.query.reason,
|
||||
}),
|
||||
}));
|
||||
|
||||
router.put(...mobileCommand('/staff/availability', {
|
||||
schema: availabilityDayUpdateSchema,
|
||||
policyAction: 'staff.availability.write',
|
||||
resource: 'staff',
|
||||
handler: handlers.updateStaffAvailabilityDay,
|
||||
}));
|
||||
|
||||
router.post(...mobileCommand('/staff/availability/quick-set', {
|
||||
schema: availabilityQuickSetSchema,
|
||||
policyAction: 'staff.availability.write',
|
||||
resource: 'staff',
|
||||
handler: handlers.quickSetStaffAvailability,
|
||||
}));
|
||||
|
||||
router.post(...mobileCommand('/staff/shifts/:shiftId/apply', {
|
||||
schema: shiftApplySchema,
|
||||
policyAction: 'staff.shifts.apply',
|
||||
resource: 'shift',
|
||||
handler: handlers.applyForShift,
|
||||
paramShape: (req) => ({ ...req.body, shiftId: req.params.shiftId }),
|
||||
}));
|
||||
|
||||
router.post(...mobileCommand('/staff/shifts/:shiftId/accept', {
|
||||
schema: shiftDecisionSchema,
|
||||
policyAction: 'staff.shifts.accept',
|
||||
resource: 'shift',
|
||||
handler: handlers.acceptPendingShift,
|
||||
paramShape: (req) => ({ ...req.body, shiftId: req.params.shiftId }),
|
||||
}));
|
||||
|
||||
router.post(...mobileCommand('/staff/shifts/:shiftId/decline', {
|
||||
schema: shiftDecisionSchema,
|
||||
policyAction: 'staff.shifts.decline',
|
||||
resource: 'shift',
|
||||
handler: handlers.declinePendingShift,
|
||||
paramShape: (req) => ({ ...req.body, shiftId: req.params.shiftId }),
|
||||
}));
|
||||
|
||||
router.post(...mobileCommand('/staff/shifts/:shiftId/request-swap', {
|
||||
schema: shiftDecisionSchema,
|
||||
policyAction: 'staff.shifts.swap',
|
||||
resource: 'shift',
|
||||
handler: handlers.requestShiftSwap,
|
||||
paramShape: (req) => ({ ...req.body, shiftId: req.params.shiftId }),
|
||||
}));
|
||||
|
||||
router.put(...mobileCommand('/staff/profile/personal-info', {
|
||||
schema: personalInfoUpdateSchema,
|
||||
policyAction: 'staff.profile.write',
|
||||
resource: 'staff',
|
||||
handler: handlers.updatePersonalInfo,
|
||||
}));
|
||||
|
||||
router.put(...mobileCommand('/staff/profile/experience', {
|
||||
schema: profileExperienceSchema,
|
||||
policyAction: 'staff.profile.write',
|
||||
resource: 'staff',
|
||||
handler: handlers.updateProfileExperience,
|
||||
}));
|
||||
|
||||
router.put(...mobileCommand('/staff/profile/locations', {
|
||||
schema: preferredLocationsUpdateSchema,
|
||||
policyAction: 'staff.profile.write',
|
||||
resource: 'staff',
|
||||
handler: handlers.updatePreferredLocations,
|
||||
}));
|
||||
|
||||
router.post(...mobileCommand('/staff/profile/emergency-contacts', {
|
||||
schema: emergencyContactCreateSchema,
|
||||
policyAction: 'staff.profile.write',
|
||||
resource: 'staff',
|
||||
handler: handlers.createEmergencyContact,
|
||||
}));
|
||||
|
||||
router.put(...mobileCommand('/staff/profile/emergency-contacts/:contactId', {
|
||||
schema: emergencyContactUpdateSchema,
|
||||
policyAction: 'staff.profile.write',
|
||||
resource: 'staff',
|
||||
handler: handlers.updateEmergencyContact,
|
||||
paramShape: (req) => ({ ...req.body, contactId: req.params.contactId }),
|
||||
}));
|
||||
|
||||
router.put(...mobileCommand('/staff/profile/tax-forms/:formType', {
|
||||
schema: taxFormDraftSchema,
|
||||
policyAction: 'staff.profile.write',
|
||||
resource: 'staff_document',
|
||||
handler: handlers.saveTaxFormDraft,
|
||||
paramShape: (req) => ({ ...req.body, formType: `${req.params.formType}`.toUpperCase() }),
|
||||
}));
|
||||
|
||||
router.post(...mobileCommand('/staff/profile/tax-forms/:formType/submit', {
|
||||
schema: taxFormSubmitSchema,
|
||||
policyAction: 'staff.profile.write',
|
||||
resource: 'staff_document',
|
||||
handler: handlers.submitTaxForm,
|
||||
paramShape: (req) => ({ ...req.body, formType: `${req.params.formType}`.toUpperCase() }),
|
||||
}));
|
||||
|
||||
router.post(...mobileCommand('/staff/profile/bank-accounts', {
|
||||
schema: bankAccountCreateSchema,
|
||||
policyAction: 'staff.profile.write',
|
||||
resource: 'account',
|
||||
handler: handlers.addStaffBankAccount,
|
||||
}));
|
||||
|
||||
router.put(...mobileCommand('/staff/profile/privacy', {
|
||||
schema: privacyUpdateSchema,
|
||||
policyAction: 'staff.profile.write',
|
||||
resource: 'staff',
|
||||
handler: handlers.updatePrivacyVisibility,
|
||||
}));
|
||||
|
||||
return router;
|
||||
}
|
||||
111
backend/command-api/src/services/actor-context.js
Normal file
111
backend/command-api/src/services/actor-context.js
Normal file
@@ -0,0 +1,111 @@
|
||||
import { AppError } from '../lib/errors.js';
|
||||
import { query } from './db.js';
|
||||
|
||||
export async function loadActorContext(uid) {
|
||||
const [userResult, tenantResult, businessResult, vendorResult, staffResult] = await Promise.all([
|
||||
query(
|
||||
`
|
||||
SELECT id AS "userId", email, display_name AS "displayName", phone, status
|
||||
FROM users
|
||||
WHERE id = $1
|
||||
`,
|
||||
[uid]
|
||||
),
|
||||
query(
|
||||
`
|
||||
SELECT tm.id AS "membershipId",
|
||||
tm.tenant_id AS "tenantId",
|
||||
tm.base_role AS role,
|
||||
t.name AS "tenantName",
|
||||
t.slug AS "tenantSlug"
|
||||
FROM tenant_memberships tm
|
||||
JOIN tenants t ON t.id = tm.tenant_id
|
||||
WHERE tm.user_id = $1
|
||||
AND tm.membership_status = 'ACTIVE'
|
||||
ORDER BY tm.created_at ASC
|
||||
LIMIT 1
|
||||
`,
|
||||
[uid]
|
||||
),
|
||||
query(
|
||||
`
|
||||
SELECT bm.id AS "membershipId",
|
||||
bm.business_id AS "businessId",
|
||||
bm.business_role AS role,
|
||||
b.business_name AS "businessName",
|
||||
b.slug AS "businessSlug",
|
||||
bm.tenant_id AS "tenantId"
|
||||
FROM business_memberships bm
|
||||
JOIN businesses b ON b.id = bm.business_id
|
||||
WHERE bm.user_id = $1
|
||||
AND bm.membership_status = 'ACTIVE'
|
||||
ORDER BY bm.created_at ASC
|
||||
LIMIT 1
|
||||
`,
|
||||
[uid]
|
||||
),
|
||||
query(
|
||||
`
|
||||
SELECT vm.id AS "membershipId",
|
||||
vm.vendor_id AS "vendorId",
|
||||
vm.vendor_role AS role,
|
||||
v.company_name AS "vendorName",
|
||||
v.slug AS "vendorSlug",
|
||||
vm.tenant_id AS "tenantId"
|
||||
FROM vendor_memberships vm
|
||||
JOIN vendors v ON v.id = vm.vendor_id
|
||||
WHERE vm.user_id = $1
|
||||
AND vm.membership_status = 'ACTIVE'
|
||||
ORDER BY vm.created_at ASC
|
||||
LIMIT 1
|
||||
`,
|
||||
[uid]
|
||||
),
|
||||
query(
|
||||
`
|
||||
SELECT s.id AS "staffId",
|
||||
s.tenant_id AS "tenantId",
|
||||
s.full_name AS "fullName",
|
||||
s.email,
|
||||
s.phone,
|
||||
s.primary_role AS "primaryRole",
|
||||
s.onboarding_status AS "onboardingStatus",
|
||||
s.status,
|
||||
s.metadata,
|
||||
w.id AS "workforceId",
|
||||
w.vendor_id AS "vendorId",
|
||||
w.workforce_number AS "workforceNumber"
|
||||
FROM staffs s
|
||||
LEFT JOIN workforce w ON w.staff_id = s.id
|
||||
WHERE s.user_id = $1
|
||||
ORDER BY s.created_at ASC
|
||||
LIMIT 1
|
||||
`,
|
||||
[uid]
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
user: userResult.rows[0] || null,
|
||||
tenant: tenantResult.rows[0] || null,
|
||||
business: businessResult.rows[0] || null,
|
||||
vendor: vendorResult.rows[0] || null,
|
||||
staff: staffResult.rows[0] || null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function requireClientContext(uid) {
|
||||
const context = await loadActorContext(uid);
|
||||
if (!context.user || !context.tenant || !context.business) {
|
||||
throw new AppError('FORBIDDEN', 'Client business context is required for this route', 403, { uid });
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export async function requireStaffContext(uid) {
|
||||
const context = await loadActorContext(uid);
|
||||
if (!context.user || !context.tenant || !context.staff) {
|
||||
throw new AppError('FORBIDDEN', 'Staff context is required for this route', 403, { uid });
|
||||
}
|
||||
return context;
|
||||
}
|
||||
84
backend/command-api/src/services/attendance-monitoring.js
Normal file
84
backend/command-api/src/services/attendance-monitoring.js
Normal file
@@ -0,0 +1,84 @@
|
||||
export async function recordGeofenceIncident(client, {
|
||||
assignment,
|
||||
actorUserId,
|
||||
locationStreamBatchId = null,
|
||||
incidentType,
|
||||
severity = 'WARNING',
|
||||
status = 'OPEN',
|
||||
effectiveClockInMode = null,
|
||||
sourceType = null,
|
||||
nfcTagUid = null,
|
||||
deviceId = null,
|
||||
latitude = null,
|
||||
longitude = null,
|
||||
accuracyMeters = null,
|
||||
distanceToClockPointMeters = null,
|
||||
withinGeofence = null,
|
||||
overrideReason = null,
|
||||
message = null,
|
||||
occurredAt = null,
|
||||
metadata = {},
|
||||
}) {
|
||||
const result = await client.query(
|
||||
`
|
||||
INSERT INTO geofence_incidents (
|
||||
tenant_id,
|
||||
business_id,
|
||||
vendor_id,
|
||||
shift_id,
|
||||
assignment_id,
|
||||
staff_id,
|
||||
actor_user_id,
|
||||
location_stream_batch_id,
|
||||
incident_type,
|
||||
severity,
|
||||
status,
|
||||
effective_clock_in_mode,
|
||||
source_type,
|
||||
nfc_tag_uid,
|
||||
device_id,
|
||||
latitude,
|
||||
longitude,
|
||||
accuracy_meters,
|
||||
distance_to_clock_point_meters,
|
||||
within_geofence,
|
||||
override_reason,
|
||||
message,
|
||||
occurred_at,
|
||||
metadata
|
||||
)
|
||||
VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, COALESCE($23::timestamptz, NOW()), $24::jsonb
|
||||
)
|
||||
RETURNING id
|
||||
`,
|
||||
[
|
||||
assignment.tenant_id,
|
||||
assignment.business_id,
|
||||
assignment.vendor_id,
|
||||
assignment.shift_id,
|
||||
assignment.id,
|
||||
assignment.staff_id,
|
||||
actorUserId,
|
||||
locationStreamBatchId,
|
||||
incidentType,
|
||||
severity,
|
||||
status,
|
||||
effectiveClockInMode,
|
||||
sourceType,
|
||||
nfcTagUid,
|
||||
deviceId,
|
||||
latitude,
|
||||
longitude,
|
||||
accuracyMeters,
|
||||
distanceToClockPointMeters,
|
||||
withinGeofence,
|
||||
overrideReason,
|
||||
message,
|
||||
occurredAt,
|
||||
JSON.stringify(metadata || {}),
|
||||
]
|
||||
);
|
||||
|
||||
return result.rows[0].id;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Storage } from '@google-cloud/storage';
|
||||
|
||||
const storage = new Storage();
|
||||
|
||||
function resolvePrivateBucket() {
|
||||
return process.env.PRIVATE_BUCKET || null;
|
||||
}
|
||||
|
||||
export async function uploadAttendanceSecurityLog({
|
||||
tenantId,
|
||||
staffId,
|
||||
assignmentId,
|
||||
proofId,
|
||||
payload,
|
||||
}) {
|
||||
const bucket = resolvePrivateBucket();
|
||||
if (!bucket) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const objectPath = [
|
||||
'attendance-security',
|
||||
tenantId,
|
||||
staffId,
|
||||
assignmentId,
|
||||
`${proofId}.json`,
|
||||
].join('/');
|
||||
|
||||
await storage.bucket(bucket).file(objectPath).save(JSON.stringify(payload), {
|
||||
resumable: false,
|
||||
contentType: 'application/json',
|
||||
metadata: {
|
||||
cacheControl: 'private, max-age=0',
|
||||
},
|
||||
});
|
||||
|
||||
return `gs://${bucket}/${objectPath}`;
|
||||
}
|
||||
285
backend/command-api/src/services/attendance-security.js
Normal file
285
backend/command-api/src/services/attendance-security.js
Normal file
@@ -0,0 +1,285 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { AppError } from '../lib/errors.js';
|
||||
import { uploadAttendanceSecurityLog } from './attendance-security-log-storage.js';
|
||||
|
||||
function parseBooleanEnv(name, fallback = false) {
|
||||
const value = process.env[name];
|
||||
if (value == null) return fallback;
|
||||
return value === 'true';
|
||||
}
|
||||
|
||||
function parseIntEnv(name, fallback) {
|
||||
const parsed = Number.parseInt(`${process.env[name] || fallback}`, 10);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function hashValue(value) {
|
||||
if (!value) return null;
|
||||
return crypto.createHash('sha256').update(`${value}`).digest('hex');
|
||||
}
|
||||
|
||||
function normalizeTimestamp(value) {
|
||||
if (!value) return null;
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
function buildRequestFingerprint({ assignmentId, actorUserId, eventType, sourceType, deviceId, nfcTagUid, capturedAt }) {
|
||||
const fingerprintSource = [assignmentId, actorUserId, eventType, sourceType, deviceId || '', nfcTagUid || '', capturedAt || ''].join('|');
|
||||
return hashValue(fingerprintSource);
|
||||
}
|
||||
|
||||
async function persistProofRecord(client, {
|
||||
proofId,
|
||||
assignment,
|
||||
actor,
|
||||
payload,
|
||||
eventType,
|
||||
proofNonce,
|
||||
proofTimestamp,
|
||||
requestFingerprint,
|
||||
attestationProvider,
|
||||
attestationTokenHash,
|
||||
attestationStatus,
|
||||
attestationReason,
|
||||
objectUri,
|
||||
metadata,
|
||||
}) {
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO attendance_security_proofs (
|
||||
id,
|
||||
tenant_id,
|
||||
assignment_id,
|
||||
shift_id,
|
||||
staff_id,
|
||||
actor_user_id,
|
||||
event_type,
|
||||
source_type,
|
||||
device_id,
|
||||
nfc_tag_uid,
|
||||
proof_nonce,
|
||||
proof_timestamp,
|
||||
request_fingerprint,
|
||||
attestation_provider,
|
||||
attestation_token_hash,
|
||||
attestation_status,
|
||||
attestation_reason,
|
||||
object_uri,
|
||||
metadata
|
||||
)
|
||||
VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12::timestamptz, $13, $14, $15, $16, $17, $18, $19::jsonb
|
||||
)
|
||||
`,
|
||||
[
|
||||
proofId,
|
||||
assignment.tenant_id,
|
||||
assignment.id,
|
||||
assignment.shift_id,
|
||||
assignment.staff_id,
|
||||
actor.uid,
|
||||
eventType,
|
||||
payload.sourceType,
|
||||
payload.deviceId || null,
|
||||
payload.nfcTagUid || null,
|
||||
proofNonce,
|
||||
proofTimestamp,
|
||||
requestFingerprint,
|
||||
attestationProvider,
|
||||
attestationTokenHash,
|
||||
attestationStatus,
|
||||
attestationReason,
|
||||
objectUri,
|
||||
JSON.stringify(metadata || {}),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
function buildBaseMetadata({ payload, capturedAt, securityCode = null, securityReason = null }) {
|
||||
return {
|
||||
capturedAt,
|
||||
proofTimestamp: payload.proofTimestamp || null,
|
||||
rawPayload: payload.rawPayload || {},
|
||||
securityCode,
|
||||
securityReason,
|
||||
notes: payload.notes || null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function recordAttendanceSecurityProof(client, {
|
||||
assignment,
|
||||
actor,
|
||||
payload,
|
||||
eventType,
|
||||
capturedAt,
|
||||
}) {
|
||||
const proofId = crypto.randomUUID();
|
||||
const proofNonce = payload.proofNonce || null;
|
||||
const proofTimestamp = normalizeTimestamp(payload.proofTimestamp || payload.capturedAt || capturedAt);
|
||||
const requestFingerprint = buildRequestFingerprint({
|
||||
assignmentId: assignment.id,
|
||||
actorUserId: actor.uid,
|
||||
eventType,
|
||||
sourceType: payload.sourceType,
|
||||
deviceId: payload.deviceId,
|
||||
nfcTagUid: payload.nfcTagUid,
|
||||
capturedAt,
|
||||
});
|
||||
const attestationProvider = payload.attestationProvider || null;
|
||||
const attestationTokenHash = hashValue(payload.attestationToken || null);
|
||||
const requiresNonce = payload.sourceType === 'NFC' && parseBooleanEnv('NFC_ENFORCE_PROOF_NONCE', false);
|
||||
const requiresDeviceId = payload.sourceType === 'NFC' && parseBooleanEnv('NFC_ENFORCE_DEVICE_ID', false);
|
||||
const requiresAttestation = payload.sourceType === 'NFC' && parseBooleanEnv('NFC_ENFORCE_ATTESTATION', false);
|
||||
const maxAgeSeconds = parseIntEnv('NFC_PROOF_MAX_AGE_SECONDS', 120);
|
||||
const baseMetadata = buildBaseMetadata({ payload, capturedAt });
|
||||
|
||||
let securityCode = null;
|
||||
let securityReason = null;
|
||||
let attestationStatus = payload.sourceType === 'NFC' ? 'NOT_PROVIDED' : 'BYPASSED';
|
||||
let attestationReason = null;
|
||||
|
||||
if (requiresDeviceId && !payload.deviceId) {
|
||||
securityCode = 'DEVICE_ID_REQUIRED';
|
||||
securityReason = 'NFC proof must include a deviceId';
|
||||
} else if (requiresNonce && !proofNonce) {
|
||||
securityCode = 'NFC_PROOF_NONCE_REQUIRED';
|
||||
securityReason = 'NFC proof must include a proofNonce';
|
||||
} else if (proofTimestamp) {
|
||||
const skewSeconds = Math.abs(new Date(capturedAt).getTime() - new Date(proofTimestamp).getTime()) / 1000;
|
||||
if (skewSeconds > maxAgeSeconds) {
|
||||
securityCode = 'NFC_PROOF_TIMESTAMP_EXPIRED';
|
||||
securityReason = `NFC proof timestamp exceeded the ${maxAgeSeconds}-second window`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!securityCode && proofNonce) {
|
||||
const replayCheck = await client.query(
|
||||
`
|
||||
SELECT id
|
||||
FROM attendance_security_proofs
|
||||
WHERE tenant_id = $1
|
||||
AND proof_nonce = $2
|
||||
LIMIT 1
|
||||
`,
|
||||
[assignment.tenant_id, proofNonce]
|
||||
);
|
||||
if (replayCheck.rowCount > 0) {
|
||||
securityCode = 'NFC_REPLAY_DETECTED';
|
||||
securityReason = 'This NFC proof nonce was already used';
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.sourceType === 'NFC') {
|
||||
if (attestationProvider || payload.attestationToken) {
|
||||
if (!attestationProvider || !payload.attestationToken) {
|
||||
securityCode = securityCode || 'ATTESTATION_PAYLOAD_INVALID';
|
||||
securityReason = securityReason || 'attestationProvider and attestationToken must be provided together';
|
||||
attestationStatus = 'REJECTED';
|
||||
attestationReason = 'Incomplete attestation payload';
|
||||
} else {
|
||||
attestationStatus = 'RECORDED_UNVERIFIED';
|
||||
attestationReason = 'Attestation payload recorded; server-side verifier not yet enabled';
|
||||
}
|
||||
}
|
||||
|
||||
if (requiresAttestation && attestationStatus !== 'RECORDED_UNVERIFIED' && attestationStatus !== 'VERIFIED') {
|
||||
securityCode = securityCode || 'ATTESTATION_REQUIRED';
|
||||
securityReason = securityReason || 'NFC proof requires device attestation';
|
||||
attestationStatus = 'REJECTED';
|
||||
attestationReason = 'Device attestation is required for NFC proof';
|
||||
}
|
||||
|
||||
if (requiresAttestation && attestationStatus === 'RECORDED_UNVERIFIED') {
|
||||
securityCode = securityCode || 'ATTESTATION_NOT_VERIFIED';
|
||||
securityReason = securityReason || 'NFC proof attestation cannot be trusted until verifier is enabled';
|
||||
attestationStatus = 'REJECTED';
|
||||
attestationReason = 'Recorded attestation is not yet verified';
|
||||
}
|
||||
}
|
||||
|
||||
const objectUri = await uploadAttendanceSecurityLog({
|
||||
tenantId: assignment.tenant_id,
|
||||
staffId: assignment.staff_id,
|
||||
assignmentId: assignment.id,
|
||||
proofId,
|
||||
payload: {
|
||||
assignmentId: assignment.id,
|
||||
shiftId: assignment.shift_id,
|
||||
staffId: assignment.staff_id,
|
||||
actorUserId: actor.uid,
|
||||
eventType,
|
||||
sourceType: payload.sourceType,
|
||||
proofNonce,
|
||||
proofTimestamp,
|
||||
deviceId: payload.deviceId || null,
|
||||
nfcTagUid: payload.nfcTagUid || null,
|
||||
requestFingerprint,
|
||||
attestationProvider,
|
||||
attestationTokenHash,
|
||||
attestationStatus,
|
||||
attestationReason,
|
||||
capturedAt,
|
||||
metadata: {
|
||||
...baseMetadata,
|
||||
securityCode,
|
||||
securityReason,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await persistProofRecord(client, {
|
||||
proofId,
|
||||
assignment,
|
||||
actor,
|
||||
payload,
|
||||
eventType,
|
||||
proofNonce,
|
||||
proofTimestamp,
|
||||
requestFingerprint,
|
||||
attestationProvider,
|
||||
attestationTokenHash,
|
||||
attestationStatus,
|
||||
attestationReason,
|
||||
objectUri,
|
||||
metadata: {
|
||||
...baseMetadata,
|
||||
securityCode,
|
||||
securityReason,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.code === '23505' && proofNonce) {
|
||||
throw new AppError('ATTENDANCE_SECURITY_FAILED', 'This NFC proof nonce was already used', 409, {
|
||||
assignmentId: assignment.id,
|
||||
proofNonce,
|
||||
securityCode: 'NFC_REPLAY_DETECTED',
|
||||
objectUri,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (securityCode) {
|
||||
throw new AppError('ATTENDANCE_SECURITY_FAILED', securityReason, 409, {
|
||||
assignmentId: assignment.id,
|
||||
proofId,
|
||||
proofNonce,
|
||||
securityCode,
|
||||
objectUri,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
proofId,
|
||||
proofNonce,
|
||||
proofTimestamp,
|
||||
attestationStatus,
|
||||
attestationReason,
|
||||
objectUri,
|
||||
};
|
||||
}
|
||||
203
backend/command-api/src/services/clock-in-policy.js
Normal file
203
backend/command-api/src/services/clock-in-policy.js
Normal file
@@ -0,0 +1,203 @@
|
||||
export const CLOCK_IN_MODES = {
|
||||
NFC_REQUIRED: 'NFC_REQUIRED',
|
||||
GEO_REQUIRED: 'GEO_REQUIRED',
|
||||
EITHER: 'EITHER',
|
||||
};
|
||||
|
||||
function toRadians(value) {
|
||||
return (value * Math.PI) / 180;
|
||||
}
|
||||
|
||||
export function distanceMeters(from, to) {
|
||||
if (
|
||||
from?.latitude == null
|
||||
|| from?.longitude == null
|
||||
|| to?.latitude == null
|
||||
|| to?.longitude == null
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const earthRadiusMeters = 6371000;
|
||||
const dLat = toRadians(Number(to.latitude) - Number(from.latitude));
|
||||
const dLon = toRadians(Number(to.longitude) - Number(from.longitude));
|
||||
const lat1 = toRadians(Number(from.latitude));
|
||||
const lat2 = toRadians(Number(to.latitude));
|
||||
|
||||
const a = Math.sin(dLat / 2) ** 2
|
||||
+ Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return Math.round(earthRadiusMeters * c);
|
||||
}
|
||||
|
||||
export function resolveEffectiveClockInPolicy(record = {}) {
|
||||
return {
|
||||
mode: record.clock_in_mode
|
||||
|| record.shift_clock_in_mode
|
||||
|| record.default_clock_in_mode
|
||||
|| CLOCK_IN_MODES.EITHER,
|
||||
allowOverride: record.allow_clock_in_override
|
||||
?? record.shift_allow_clock_in_override
|
||||
?? record.default_allow_clock_in_override
|
||||
?? true,
|
||||
};
|
||||
}
|
||||
|
||||
function validateNfc(expectedNfcTag, payload) {
|
||||
if (payload.sourceType !== 'NFC') {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'NFC_REQUIRED',
|
||||
reason: 'Clock-in requires NFC',
|
||||
overrideable: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (!payload.nfcTagUid) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'NFC_REQUIRED',
|
||||
reason: 'NFC tag is required',
|
||||
overrideable: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (!expectedNfcTag) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'NFC_NOT_CONFIGURED',
|
||||
reason: 'Hub is not configured for NFC clock-in',
|
||||
overrideable: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (payload.nfcTagUid !== expectedNfcTag) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'NFC_MISMATCH',
|
||||
reason: 'NFC tag mismatch',
|
||||
overrideable: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
distance: null,
|
||||
withinGeofence: null,
|
||||
};
|
||||
}
|
||||
|
||||
function validateGeo(expectedPoint, radius, payload) {
|
||||
if (payload.latitude == null || payload.longitude == null) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'LOCATION_REQUIRED',
|
||||
reason: 'Location coordinates are required',
|
||||
overrideable: true,
|
||||
distance: null,
|
||||
withinGeofence: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
expectedPoint?.latitude == null
|
||||
|| expectedPoint?.longitude == null
|
||||
|| radius == null
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'GEOFENCE_NOT_CONFIGURED',
|
||||
reason: 'Clock-in geofence is not configured',
|
||||
overrideable: true,
|
||||
distance: null,
|
||||
withinGeofence: null,
|
||||
};
|
||||
}
|
||||
|
||||
const distance = distanceMeters({
|
||||
latitude: payload.latitude,
|
||||
longitude: payload.longitude,
|
||||
}, expectedPoint);
|
||||
|
||||
if (distance == null) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'LOCATION_REQUIRED',
|
||||
reason: 'Location coordinates are required',
|
||||
overrideable: true,
|
||||
distance: null,
|
||||
withinGeofence: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (distance > radius) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'OUTSIDE_GEOFENCE',
|
||||
reason: `Outside geofence by ${distance - radius} meters`,
|
||||
overrideable: true,
|
||||
distance,
|
||||
withinGeofence: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
distance,
|
||||
withinGeofence: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function evaluateClockInAttempt(record, payload) {
|
||||
const policy = resolveEffectiveClockInPolicy(record);
|
||||
const expectedPoint = {
|
||||
latitude: record.expected_latitude,
|
||||
longitude: record.expected_longitude,
|
||||
};
|
||||
const radius = record.geofence_radius_meters;
|
||||
const expectedNfcTag = record.expected_nfc_tag_uid;
|
||||
|
||||
let proofResult;
|
||||
if (policy.mode === CLOCK_IN_MODES.NFC_REQUIRED) {
|
||||
proofResult = validateNfc(expectedNfcTag, payload);
|
||||
} else if (policy.mode === CLOCK_IN_MODES.GEO_REQUIRED) {
|
||||
proofResult = validateGeo(expectedPoint, radius, payload);
|
||||
} else {
|
||||
proofResult = payload.sourceType === 'NFC'
|
||||
? validateNfc(expectedNfcTag, payload)
|
||||
: validateGeo(expectedPoint, radius, payload);
|
||||
}
|
||||
|
||||
if (proofResult.ok) {
|
||||
return {
|
||||
effectiveClockInMode: policy.mode,
|
||||
allowOverride: policy.allowOverride,
|
||||
validationStatus: 'ACCEPTED',
|
||||
validationCode: null,
|
||||
validationReason: null,
|
||||
distance: proofResult.distance ?? null,
|
||||
withinGeofence: proofResult.withinGeofence ?? null,
|
||||
overrideUsed: false,
|
||||
overrideable: false,
|
||||
};
|
||||
}
|
||||
|
||||
const rawOverrideReason = payload.overrideReason || payload.notes || null;
|
||||
const overrideReason = typeof rawOverrideReason === 'string' ? rawOverrideReason.trim() : '';
|
||||
const canOverride = policy.allowOverride
|
||||
&& proofResult.overrideable === true
|
||||
&& overrideReason.length > 0;
|
||||
|
||||
return {
|
||||
effectiveClockInMode: policy.mode,
|
||||
allowOverride: policy.allowOverride,
|
||||
validationStatus: canOverride ? 'FLAGGED' : 'REJECTED',
|
||||
validationCode: proofResult.code,
|
||||
validationReason: proofResult.reason,
|
||||
distance: proofResult.distance ?? null,
|
||||
withinGeofence: proofResult.withinGeofence ?? null,
|
||||
overrideUsed: canOverride,
|
||||
overrideReason: overrideReason || null,
|
||||
overrideable: proofResult.overrideable === true,
|
||||
};
|
||||
}
|
||||
1648
backend/command-api/src/services/command-service.js
Normal file
1648
backend/command-api/src/services/command-service.js
Normal file
File diff suppressed because it is too large
Load Diff
105
backend/command-api/src/services/db.js
Normal file
105
backend/command-api/src/services/db.js
Normal file
@@ -0,0 +1,105 @@
|
||||
import pg from 'pg';
|
||||
|
||||
const { Pool, types } = pg;
|
||||
|
||||
function parseNumericDatabaseValue(value) {
|
||||
if (value == null) return value;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : value;
|
||||
}
|
||||
|
||||
types.setTypeParser(types.builtins.INT8, parseNumericDatabaseValue);
|
||||
types.setTypeParser(types.builtins.NUMERIC, parseNumericDatabaseValue);
|
||||
|
||||
let pool;
|
||||
|
||||
function parseIntOrDefault(value, fallback) {
|
||||
const parsed = Number.parseInt(`${value || fallback}`, 10);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
export function resolveDatabasePoolConfig({
|
||||
preferIdempotency = false,
|
||||
maxEnvVar = 'DB_POOL_MAX',
|
||||
} = {}) {
|
||||
const primaryUrl = preferIdempotency
|
||||
? process.env.IDEMPOTENCY_DATABASE_URL || process.env.DATABASE_URL
|
||||
: process.env.DATABASE_URL || process.env.IDEMPOTENCY_DATABASE_URL;
|
||||
|
||||
if (primaryUrl) {
|
||||
return {
|
||||
connectionString: primaryUrl,
|
||||
max: parseIntOrDefault(process.env[maxEnvVar], 10),
|
||||
idleTimeoutMillis: parseIntOrDefault(process.env.DB_IDLE_TIMEOUT_MS, 30000),
|
||||
};
|
||||
}
|
||||
|
||||
const user = process.env.DB_USER;
|
||||
const password = process.env.DB_PASSWORD;
|
||||
const database = process.env.DB_NAME;
|
||||
const host = process.env.DB_HOST || (
|
||||
process.env.INSTANCE_CONNECTION_NAME
|
||||
? `/cloudsql/${process.env.INSTANCE_CONNECTION_NAME}`
|
||||
: ''
|
||||
);
|
||||
|
||||
if (!user || password == null || !database || !host) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
host,
|
||||
port: parseIntOrDefault(process.env.DB_PORT, 5432),
|
||||
user,
|
||||
password,
|
||||
database,
|
||||
max: parseIntOrDefault(process.env[maxEnvVar], 10),
|
||||
idleTimeoutMillis: parseIntOrDefault(process.env.DB_IDLE_TIMEOUT_MS, 30000),
|
||||
};
|
||||
}
|
||||
|
||||
export function isDatabaseConfigured() {
|
||||
return Boolean(resolveDatabasePoolConfig());
|
||||
}
|
||||
|
||||
function getPool() {
|
||||
if (!pool) {
|
||||
const resolved = resolveDatabasePoolConfig();
|
||||
if (!resolved) {
|
||||
throw new Error('Database connection settings are required');
|
||||
}
|
||||
pool = new Pool(resolved);
|
||||
}
|
||||
return pool;
|
||||
}
|
||||
|
||||
export async function query(text, params = []) {
|
||||
return getPool().query(text, params);
|
||||
}
|
||||
|
||||
export async function withTransaction(work) {
|
||||
const client = await getPool().connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const result = await work(client);
|
||||
await client.query('COMMIT');
|
||||
return result;
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkDatabaseHealth() {
|
||||
const result = await query('SELECT 1 AS ok');
|
||||
return result.rows[0]?.ok === 1;
|
||||
}
|
||||
|
||||
export async function closePool() {
|
||||
if (pool) {
|
||||
await pool.end();
|
||||
pool = null;
|
||||
}
|
||||
}
|
||||
19
backend/command-api/src/services/firebase-admin.js
Normal file
19
backend/command-api/src/services/firebase-admin.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import { applicationDefault, getApps, initializeApp } from 'firebase-admin/app';
|
||||
import { getAuth } from 'firebase-admin/auth';
|
||||
import { getMessaging } from 'firebase-admin/messaging';
|
||||
|
||||
export function ensureFirebaseAdminApp() {
|
||||
if (getApps().length === 0) {
|
||||
initializeApp({ credential: applicationDefault() });
|
||||
}
|
||||
}
|
||||
|
||||
export function getFirebaseAdminAuth() {
|
||||
ensureFirebaseAdminApp();
|
||||
return getAuth();
|
||||
}
|
||||
|
||||
export function getFirebaseAdminMessaging() {
|
||||
ensureFirebaseAdminApp();
|
||||
return getMessaging();
|
||||
}
|
||||
@@ -1,13 +1,5 @@
|
||||
import { applicationDefault, getApps, initializeApp } from 'firebase-admin/app';
|
||||
import { getAuth } from 'firebase-admin/auth';
|
||||
|
||||
function ensureAdminApp() {
|
||||
if (getApps().length === 0) {
|
||||
initializeApp({ credential: applicationDefault() });
|
||||
}
|
||||
}
|
||||
import { getFirebaseAdminAuth } from './firebase-admin.js';
|
||||
|
||||
export async function verifyFirebaseToken(token) {
|
||||
ensureAdminApp();
|
||||
return getAuth().verifyIdToken(token);
|
||||
return getFirebaseAdminAuth().verifyIdToken(token);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Pool } from 'pg';
|
||||
import { resolveDatabasePoolConfig } from './db.js';
|
||||
|
||||
const DEFAULT_TTL_SECONDS = Number.parseInt(process.env.IDEMPOTENCY_TTL_SECONDS || '86400', 10);
|
||||
const CLEANUP_EVERY_OPS = Number.parseInt(process.env.IDEMPOTENCY_CLEANUP_EVERY_OPS || '100', 10);
|
||||
@@ -12,9 +13,9 @@ function shouldUseSqlStore() {
|
||||
return false;
|
||||
}
|
||||
if (mode === 'sql') {
|
||||
return true;
|
||||
return Boolean(resolveDatabasePoolConfig({ preferIdempotency: true, maxEnvVar: 'IDEMPOTENCY_DB_POOL_MAX' }));
|
||||
}
|
||||
return Boolean(process.env.IDEMPOTENCY_DATABASE_URL);
|
||||
return Boolean(resolveDatabasePoolConfig({ preferIdempotency: true, maxEnvVar: 'IDEMPOTENCY_DB_POOL_MAX' }));
|
||||
}
|
||||
|
||||
function gcExpiredMemoryRecords(now = Date.now()) {
|
||||
@@ -55,15 +56,16 @@ function createMemoryAdapter() {
|
||||
}
|
||||
|
||||
async function createSqlAdapter() {
|
||||
const connectionString = process.env.IDEMPOTENCY_DATABASE_URL;
|
||||
if (!connectionString) {
|
||||
throw new Error('IDEMPOTENCY_DATABASE_URL is required for sql idempotency store');
|
||||
const poolConfig = resolveDatabasePoolConfig({
|
||||
preferIdempotency: true,
|
||||
maxEnvVar: 'IDEMPOTENCY_DB_POOL_MAX',
|
||||
});
|
||||
|
||||
if (!poolConfig) {
|
||||
throw new Error('Database connection settings are required for sql idempotency store');
|
||||
}
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString,
|
||||
max: Number.parseInt(process.env.IDEMPOTENCY_DB_POOL_MAX || '5', 10),
|
||||
});
|
||||
const pool = new Pool(poolConfig);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS command_idempotency (
|
||||
|
||||
38
backend/command-api/src/services/location-log-storage.js
Normal file
38
backend/command-api/src/services/location-log-storage.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Storage } from '@google-cloud/storage';
|
||||
|
||||
const storage = new Storage();
|
||||
|
||||
function resolvePrivateBucket() {
|
||||
return process.env.PRIVATE_BUCKET || null;
|
||||
}
|
||||
|
||||
export async function uploadLocationBatch({
|
||||
tenantId,
|
||||
staffId,
|
||||
assignmentId,
|
||||
batchId,
|
||||
payload,
|
||||
}) {
|
||||
const bucket = resolvePrivateBucket();
|
||||
if (!bucket) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const objectPath = [
|
||||
'location-streams',
|
||||
tenantId,
|
||||
staffId,
|
||||
assignmentId,
|
||||
`${batchId}.json`,
|
||||
].join('/');
|
||||
|
||||
await storage.bucket(bucket).file(objectPath).save(JSON.stringify(payload), {
|
||||
resumable: false,
|
||||
contentType: 'application/json',
|
||||
metadata: {
|
||||
cacheControl: 'private, max-age=0',
|
||||
},
|
||||
});
|
||||
|
||||
return `gs://${bucket}/${objectPath}`;
|
||||
}
|
||||
2867
backend/command-api/src/services/mobile-command-service.js
Normal file
2867
backend/command-api/src/services/mobile-command-service.js
Normal file
File diff suppressed because it is too large
Load Diff
220
backend/command-api/src/services/notification-device-tokens.js
Normal file
220
backend/command-api/src/services/notification-device-tokens.js
Normal file
@@ -0,0 +1,220 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
export const PUSH_PROVIDERS = {
|
||||
FCM: 'FCM',
|
||||
APNS: 'APNS',
|
||||
WEB_PUSH: 'WEB_PUSH',
|
||||
};
|
||||
|
||||
export const PUSH_PLATFORMS = {
|
||||
IOS: 'IOS',
|
||||
ANDROID: 'ANDROID',
|
||||
WEB: 'WEB',
|
||||
};
|
||||
|
||||
export function hashPushToken(pushToken) {
|
||||
return crypto.createHash('sha256').update(`${pushToken || ''}`).digest('hex');
|
||||
}
|
||||
|
||||
export async function registerPushToken(client, {
|
||||
tenantId,
|
||||
userId,
|
||||
staffId = null,
|
||||
businessMembershipId = null,
|
||||
vendorMembershipId = null,
|
||||
provider = PUSH_PROVIDERS.FCM,
|
||||
platform,
|
||||
pushToken,
|
||||
deviceId = null,
|
||||
appVersion = null,
|
||||
appBuild = null,
|
||||
locale = null,
|
||||
timezone = null,
|
||||
notificationsEnabled = true,
|
||||
metadata = {},
|
||||
}) {
|
||||
const tokenHash = hashPushToken(pushToken);
|
||||
const result = await client.query(
|
||||
`
|
||||
INSERT INTO device_push_tokens (
|
||||
tenant_id,
|
||||
user_id,
|
||||
staff_id,
|
||||
business_membership_id,
|
||||
vendor_membership_id,
|
||||
provider,
|
||||
platform,
|
||||
push_token,
|
||||
token_hash,
|
||||
device_id,
|
||||
app_version,
|
||||
app_build,
|
||||
locale,
|
||||
timezone,
|
||||
notifications_enabled,
|
||||
invalidated_at,
|
||||
invalidation_reason,
|
||||
last_registered_at,
|
||||
last_seen_at,
|
||||
metadata
|
||||
)
|
||||
VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, NULL, NULL, NOW(), NOW(), $16::jsonb
|
||||
)
|
||||
ON CONFLICT (provider, token_hash) DO UPDATE
|
||||
SET tenant_id = EXCLUDED.tenant_id,
|
||||
user_id = EXCLUDED.user_id,
|
||||
staff_id = EXCLUDED.staff_id,
|
||||
business_membership_id = EXCLUDED.business_membership_id,
|
||||
vendor_membership_id = EXCLUDED.vendor_membership_id,
|
||||
platform = EXCLUDED.platform,
|
||||
push_token = EXCLUDED.push_token,
|
||||
device_id = EXCLUDED.device_id,
|
||||
app_version = EXCLUDED.app_version,
|
||||
app_build = EXCLUDED.app_build,
|
||||
locale = EXCLUDED.locale,
|
||||
timezone = EXCLUDED.timezone,
|
||||
notifications_enabled = EXCLUDED.notifications_enabled,
|
||||
invalidated_at = NULL,
|
||||
invalidation_reason = NULL,
|
||||
last_registered_at = NOW(),
|
||||
last_seen_at = NOW(),
|
||||
metadata = COALESCE(device_push_tokens.metadata, '{}'::jsonb) || EXCLUDED.metadata,
|
||||
updated_at = NOW()
|
||||
RETURNING id,
|
||||
tenant_id AS "tenantId",
|
||||
user_id AS "userId",
|
||||
staff_id AS "staffId",
|
||||
business_membership_id AS "businessMembershipId",
|
||||
vendor_membership_id AS "vendorMembershipId",
|
||||
provider,
|
||||
platform,
|
||||
device_id AS "deviceId",
|
||||
notifications_enabled AS "notificationsEnabled"
|
||||
`,
|
||||
[
|
||||
tenantId,
|
||||
userId,
|
||||
staffId,
|
||||
businessMembershipId,
|
||||
vendorMembershipId,
|
||||
provider,
|
||||
platform,
|
||||
pushToken,
|
||||
tokenHash,
|
||||
deviceId,
|
||||
appVersion,
|
||||
appBuild,
|
||||
locale,
|
||||
timezone,
|
||||
notificationsEnabled,
|
||||
JSON.stringify(metadata || {}),
|
||||
]
|
||||
);
|
||||
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
export async function unregisterPushToken(client, {
|
||||
tenantId,
|
||||
userId,
|
||||
tokenId = null,
|
||||
pushToken = null,
|
||||
reason = 'USER_REQUESTED',
|
||||
}) {
|
||||
const tokenHash = pushToken ? hashPushToken(pushToken) : null;
|
||||
const result = await client.query(
|
||||
`
|
||||
UPDATE device_push_tokens
|
||||
SET notifications_enabled = FALSE,
|
||||
invalidated_at = NOW(),
|
||||
invalidation_reason = $4,
|
||||
updated_at = NOW()
|
||||
WHERE tenant_id = $1
|
||||
AND user_id = $2
|
||||
AND (
|
||||
($3::uuid IS NOT NULL AND id = $3::uuid)
|
||||
OR
|
||||
($5::text IS NOT NULL AND token_hash = $5::text)
|
||||
)
|
||||
RETURNING id,
|
||||
provider,
|
||||
platform,
|
||||
device_id AS "deviceId"
|
||||
`,
|
||||
[tenantId, userId, tokenId, reason, tokenHash]
|
||||
);
|
||||
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
export async function resolveNotificationTargetTokens(client, notification) {
|
||||
const result = await client.query(
|
||||
`
|
||||
WITH recipient_users AS (
|
||||
SELECT $2::text AS user_id
|
||||
WHERE $2::text IS NOT NULL
|
||||
UNION
|
||||
SELECT bm.user_id
|
||||
FROM business_memberships bm
|
||||
WHERE $3::uuid IS NOT NULL
|
||||
AND bm.id = $3::uuid
|
||||
UNION
|
||||
SELECT s.user_id
|
||||
FROM staffs s
|
||||
WHERE $4::uuid IS NOT NULL
|
||||
AND s.id = $4::uuid
|
||||
)
|
||||
SELECT
|
||||
dpt.id,
|
||||
dpt.user_id AS "userId",
|
||||
dpt.staff_id AS "staffId",
|
||||
dpt.provider,
|
||||
dpt.platform,
|
||||
dpt.push_token AS "pushToken",
|
||||
dpt.device_id AS "deviceId",
|
||||
dpt.metadata
|
||||
FROM device_push_tokens dpt
|
||||
JOIN recipient_users ru ON ru.user_id = dpt.user_id
|
||||
WHERE dpt.tenant_id = $1
|
||||
AND dpt.notifications_enabled = TRUE
|
||||
AND dpt.invalidated_at IS NULL
|
||||
ORDER BY dpt.last_seen_at DESC, dpt.created_at DESC
|
||||
`,
|
||||
[
|
||||
notification.tenant_id,
|
||||
notification.recipient_user_id,
|
||||
notification.recipient_business_membership_id,
|
||||
notification.recipient_staff_id,
|
||||
]
|
||||
);
|
||||
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
export async function markPushTokenInvalid(client, tokenId, reason) {
|
||||
await client.query(
|
||||
`
|
||||
UPDATE device_push_tokens
|
||||
SET notifications_enabled = FALSE,
|
||||
invalidated_at = NOW(),
|
||||
invalidation_reason = $2,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`,
|
||||
[tokenId, reason]
|
||||
);
|
||||
}
|
||||
|
||||
export async function touchPushTokenDelivery(client, tokenId) {
|
||||
await client.query(
|
||||
`
|
||||
UPDATE device_push_tokens
|
||||
SET last_delivery_at = NOW(),
|
||||
last_seen_at = NOW(),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`,
|
||||
[tokenId]
|
||||
);
|
||||
}
|
||||
348
backend/command-api/src/services/notification-dispatcher.js
Normal file
348
backend/command-api/src/services/notification-dispatcher.js
Normal file
@@ -0,0 +1,348 @@
|
||||
import { query, withTransaction } from './db.js';
|
||||
import { enqueueNotification } from './notification-outbox.js';
|
||||
import {
|
||||
markPushTokenInvalid,
|
||||
resolveNotificationTargetTokens,
|
||||
touchPushTokenDelivery,
|
||||
} from './notification-device-tokens.js';
|
||||
import { createPushSender } from './notification-fcm.js';
|
||||
|
||||
function parseIntEnv(name, fallback) {
|
||||
const parsed = Number.parseInt(`${process.env[name] || fallback}`, 10);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function parseBooleanEnv(name, fallback = false) {
|
||||
const value = process.env[name];
|
||||
if (value == null) return fallback;
|
||||
return value === 'true';
|
||||
}
|
||||
|
||||
function parseListEnv(name, fallback = []) {
|
||||
const raw = process.env[name];
|
||||
if (!raw) return fallback;
|
||||
return raw.split(',').map((value) => Number.parseInt(value.trim(), 10)).filter((value) => Number.isFinite(value) && value >= 0);
|
||||
}
|
||||
|
||||
export function computeRetryDelayMinutes(attemptNumber) {
|
||||
return Math.min(5 * (2 ** Math.max(attemptNumber - 1, 0)), 60);
|
||||
}
|
||||
|
||||
async function recordDeliveryAttempt(client, {
|
||||
notificationId,
|
||||
devicePushTokenId = null,
|
||||
provider,
|
||||
deliveryStatus,
|
||||
providerMessageId = null,
|
||||
attemptNumber,
|
||||
errorCode = null,
|
||||
errorMessage = null,
|
||||
responsePayload = {},
|
||||
sentAt = null,
|
||||
}) {
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO notification_deliveries (
|
||||
notification_outbox_id,
|
||||
device_push_token_id,
|
||||
provider,
|
||||
delivery_status,
|
||||
provider_message_id,
|
||||
attempt_number,
|
||||
error_code,
|
||||
error_message,
|
||||
response_payload,
|
||||
sent_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10::timestamptz)
|
||||
`,
|
||||
[
|
||||
notificationId,
|
||||
devicePushTokenId,
|
||||
provider,
|
||||
deliveryStatus,
|
||||
providerMessageId,
|
||||
attemptNumber,
|
||||
errorCode,
|
||||
errorMessage,
|
||||
JSON.stringify(responsePayload || {}),
|
||||
sentAt,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
async function claimDueNotifications(limit) {
|
||||
return withTransaction(async (client) => {
|
||||
const result = await client.query(
|
||||
`
|
||||
WITH due AS (
|
||||
SELECT id
|
||||
FROM notification_outbox
|
||||
WHERE (
|
||||
status = 'PENDING'
|
||||
OR (
|
||||
status = 'PROCESSING'
|
||||
AND updated_at <= NOW() - INTERVAL '10 minutes'
|
||||
)
|
||||
)
|
||||
AND scheduled_at <= NOW()
|
||||
ORDER BY
|
||||
CASE priority
|
||||
WHEN 'CRITICAL' THEN 1
|
||||
WHEN 'HIGH' THEN 2
|
||||
WHEN 'NORMAL' THEN 3
|
||||
ELSE 4
|
||||
END,
|
||||
scheduled_at ASC,
|
||||
created_at ASC
|
||||
LIMIT $1
|
||||
FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
UPDATE notification_outbox n
|
||||
SET status = 'PROCESSING',
|
||||
attempts = n.attempts + 1,
|
||||
updated_at = NOW()
|
||||
FROM due
|
||||
WHERE n.id = due.id
|
||||
RETURNING n.*
|
||||
`,
|
||||
[limit]
|
||||
);
|
||||
return result.rows;
|
||||
});
|
||||
}
|
||||
|
||||
async function markNotificationSent(notificationId) {
|
||||
await query(
|
||||
`
|
||||
UPDATE notification_outbox
|
||||
SET status = 'SENT',
|
||||
sent_at = NOW(),
|
||||
last_error = NULL,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`,
|
||||
[notificationId]
|
||||
);
|
||||
}
|
||||
|
||||
async function markNotificationFailed(notificationId, lastError) {
|
||||
await query(
|
||||
`
|
||||
UPDATE notification_outbox
|
||||
SET status = 'FAILED',
|
||||
last_error = $2,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`,
|
||||
[notificationId, lastError]
|
||||
);
|
||||
}
|
||||
|
||||
async function requeueNotification(notificationId, attemptNumber, lastError) {
|
||||
const delayMinutes = computeRetryDelayMinutes(attemptNumber);
|
||||
await query(
|
||||
`
|
||||
UPDATE notification_outbox
|
||||
SET status = 'PENDING',
|
||||
last_error = $2,
|
||||
scheduled_at = NOW() + (($3::text || ' minutes')::interval),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`,
|
||||
[notificationId, lastError, String(delayMinutes)]
|
||||
);
|
||||
}
|
||||
|
||||
async function enqueueDueShiftReminders() {
|
||||
const enabled = parseBooleanEnv('SHIFT_REMINDERS_ENABLED', true);
|
||||
if (!enabled) {
|
||||
return { enqueued: 0 };
|
||||
}
|
||||
|
||||
const leadMinutesList = parseListEnv('SHIFT_REMINDER_LEAD_MINUTES', [60, 15]);
|
||||
const reminderWindowMinutes = parseIntEnv('SHIFT_REMINDER_WINDOW_MINUTES', 5);
|
||||
let enqueued = 0;
|
||||
|
||||
await withTransaction(async (client) => {
|
||||
for (const leadMinutes of leadMinutesList) {
|
||||
const result = await client.query(
|
||||
`
|
||||
SELECT
|
||||
a.id,
|
||||
a.tenant_id,
|
||||
a.business_id,
|
||||
a.shift_id,
|
||||
a.staff_id,
|
||||
s.title AS shift_title,
|
||||
s.starts_at,
|
||||
cp.label AS hub_label,
|
||||
st.user_id
|
||||
FROM assignments a
|
||||
JOIN shifts s ON s.id = a.shift_id
|
||||
JOIN staffs st ON st.id = a.staff_id
|
||||
LEFT JOIN clock_points cp ON cp.id = s.clock_point_id
|
||||
WHERE a.status IN ('ASSIGNED', 'ACCEPTED')
|
||||
AND st.user_id IS NOT NULL
|
||||
AND s.starts_at >= NOW() + (($1::int - $2::int) * INTERVAL '1 minute')
|
||||
AND s.starts_at < NOW() + (($1::int + $2::int) * INTERVAL '1 minute')
|
||||
`,
|
||||
[leadMinutes, reminderWindowMinutes]
|
||||
);
|
||||
|
||||
for (const row of result.rows) {
|
||||
const dedupeKey = [
|
||||
'notify',
|
||||
'SHIFT_START_REMINDER',
|
||||
row.id,
|
||||
leadMinutes,
|
||||
].join(':');
|
||||
|
||||
await enqueueNotification(client, {
|
||||
tenantId: row.tenant_id,
|
||||
businessId: row.business_id,
|
||||
shiftId: row.shift_id,
|
||||
assignmentId: row.id,
|
||||
audienceType: 'USER',
|
||||
recipientUserId: row.user_id,
|
||||
channel: 'PUSH',
|
||||
notificationType: 'SHIFT_START_REMINDER',
|
||||
priority: leadMinutes <= 15 ? 'HIGH' : 'NORMAL',
|
||||
dedupeKey,
|
||||
subject: leadMinutes <= 15 ? 'Shift starting soon' : 'Upcoming shift reminder',
|
||||
body: `${row.shift_title || 'Your shift'} at ${row.hub_label || 'the assigned hub'} starts in ${leadMinutes} minutes`,
|
||||
payload: {
|
||||
assignmentId: row.id,
|
||||
shiftId: row.shift_id,
|
||||
leadMinutes,
|
||||
startsAt: row.starts_at,
|
||||
},
|
||||
});
|
||||
enqueued += 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { enqueued };
|
||||
}
|
||||
|
||||
async function settleNotification(notification, deliveryResults, maxAttempts) {
|
||||
const successCount = deliveryResults.filter((result) => result.deliveryStatus === 'SENT').length;
|
||||
const simulatedCount = deliveryResults.filter((result) => result.deliveryStatus === 'SIMULATED').length;
|
||||
const transientCount = deliveryResults.filter((result) => result.transient).length;
|
||||
const invalidCount = deliveryResults.filter((result) => result.deliveryStatus === 'INVALID_TOKEN').length;
|
||||
|
||||
await withTransaction(async (client) => {
|
||||
for (const result of deliveryResults) {
|
||||
await recordDeliveryAttempt(client, {
|
||||
notificationId: notification.id,
|
||||
devicePushTokenId: result.tokenId,
|
||||
provider: result.provider || 'FCM',
|
||||
deliveryStatus: result.deliveryStatus,
|
||||
providerMessageId: result.providerMessageId || null,
|
||||
attemptNumber: notification.attempts,
|
||||
errorCode: result.errorCode || null,
|
||||
errorMessage: result.errorMessage || null,
|
||||
responsePayload: result.responsePayload || {},
|
||||
sentAt: result.deliveryStatus === 'SENT' || result.deliveryStatus === 'SIMULATED'
|
||||
? new Date().toISOString()
|
||||
: null,
|
||||
});
|
||||
|
||||
if (result.deliveryStatus === 'INVALID_TOKEN' && result.tokenId) {
|
||||
await markPushTokenInvalid(client, result.tokenId, result.errorCode || 'INVALID_TOKEN');
|
||||
}
|
||||
|
||||
if ((result.deliveryStatus === 'SENT' || result.deliveryStatus === 'SIMULATED') && result.tokenId) {
|
||||
await touchPushTokenDelivery(client, result.tokenId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (successCount > 0 || simulatedCount > 0) {
|
||||
await markNotificationSent(notification.id);
|
||||
return {
|
||||
status: 'SENT',
|
||||
successCount,
|
||||
simulatedCount,
|
||||
invalidCount,
|
||||
};
|
||||
}
|
||||
|
||||
if (transientCount > 0 && notification.attempts < maxAttempts) {
|
||||
const errorSummary = deliveryResults
|
||||
.map((result) => result.errorCode || result.errorMessage || result.deliveryStatus)
|
||||
.filter(Boolean)
|
||||
.join('; ');
|
||||
await requeueNotification(notification.id, notification.attempts, errorSummary || 'Transient delivery failure');
|
||||
return {
|
||||
status: 'REQUEUED',
|
||||
successCount,
|
||||
simulatedCount,
|
||||
invalidCount,
|
||||
};
|
||||
}
|
||||
|
||||
const failureSummary = deliveryResults
|
||||
.map((result) => result.errorCode || result.errorMessage || result.deliveryStatus)
|
||||
.filter(Boolean)
|
||||
.join('; ');
|
||||
await markNotificationFailed(notification.id, failureSummary || 'Push delivery failed');
|
||||
return {
|
||||
status: 'FAILED',
|
||||
successCount,
|
||||
simulatedCount,
|
||||
invalidCount,
|
||||
};
|
||||
}
|
||||
|
||||
export async function dispatchPendingNotifications({
|
||||
limit = parseIntEnv('NOTIFICATION_BATCH_LIMIT', 50),
|
||||
sender = createPushSender(),
|
||||
} = {}) {
|
||||
const maxAttempts = parseIntEnv('NOTIFICATION_MAX_ATTEMPTS', 5);
|
||||
const reminderSummary = await enqueueDueShiftReminders();
|
||||
const claimed = await claimDueNotifications(limit);
|
||||
|
||||
const summary = {
|
||||
remindersEnqueued: reminderSummary.enqueued,
|
||||
claimed: claimed.length,
|
||||
sent: 0,
|
||||
requeued: 0,
|
||||
failed: 0,
|
||||
simulated: 0,
|
||||
invalidTokens: 0,
|
||||
skipped: 0,
|
||||
};
|
||||
|
||||
for (const notification of claimed) {
|
||||
const tokens = await resolveNotificationTargetTokens({ query }, notification);
|
||||
if (tokens.length === 0) {
|
||||
await withTransaction(async (client) => {
|
||||
await recordDeliveryAttempt(client, {
|
||||
notificationId: notification.id,
|
||||
provider: 'FCM',
|
||||
deliveryStatus: 'SKIPPED',
|
||||
attemptNumber: notification.attempts,
|
||||
errorCode: 'NO_ACTIVE_PUSH_TOKENS',
|
||||
errorMessage: 'No active push tokens registered for notification recipient',
|
||||
responsePayload: { recipient: notification.recipient_user_id || notification.recipient_staff_id || notification.recipient_business_membership_id || null },
|
||||
});
|
||||
});
|
||||
await markNotificationFailed(notification.id, 'No active push tokens registered for notification recipient');
|
||||
summary.failed += 1;
|
||||
summary.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const deliveryResults = await sender.send(notification, tokens);
|
||||
const outcome = await settleNotification(notification, deliveryResults, maxAttempts);
|
||||
if (outcome.status === 'SENT') summary.sent += 1;
|
||||
if (outcome.status === 'REQUEUED') summary.requeued += 1;
|
||||
if (outcome.status === 'FAILED') summary.failed += 1;
|
||||
summary.simulated += outcome.simulatedCount || 0;
|
||||
summary.invalidTokens += outcome.invalidCount || 0;
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
116
backend/command-api/src/services/notification-fcm.js
Normal file
116
backend/command-api/src/services/notification-fcm.js
Normal file
@@ -0,0 +1,116 @@
|
||||
import { getFirebaseAdminMessaging } from './firebase-admin.js';
|
||||
|
||||
const INVALID_TOKEN_ERROR_CODES = new Set([
|
||||
'messaging/invalid-registration-token',
|
||||
'messaging/registration-token-not-registered',
|
||||
]);
|
||||
|
||||
const TRANSIENT_ERROR_CODES = new Set([
|
||||
'messaging/internal-error',
|
||||
'messaging/server-unavailable',
|
||||
'messaging/unknown-error',
|
||||
'app/network-error',
|
||||
]);
|
||||
|
||||
function mapPriority(priority) {
|
||||
return priority === 'CRITICAL' || priority === 'HIGH' ? 'high' : 'normal';
|
||||
}
|
||||
|
||||
function buildDataPayload(notification) {
|
||||
return {
|
||||
notificationId: notification.id,
|
||||
notificationType: notification.notification_type,
|
||||
priority: notification.priority,
|
||||
tenantId: notification.tenant_id,
|
||||
businessId: notification.business_id || '',
|
||||
shiftId: notification.shift_id || '',
|
||||
assignmentId: notification.assignment_id || '',
|
||||
payload: JSON.stringify(notification.payload || {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function classifyMessagingError(errorCode) {
|
||||
if (!errorCode) return 'FAILED';
|
||||
if (INVALID_TOKEN_ERROR_CODES.has(errorCode)) return 'INVALID_TOKEN';
|
||||
if (TRANSIENT_ERROR_CODES.has(errorCode)) return 'RETRYABLE';
|
||||
return 'FAILED';
|
||||
}
|
||||
|
||||
export function createPushSender({ deliveryMode = process.env.PUSH_DELIVERY_MODE || 'live' } = {}) {
|
||||
return {
|
||||
async send(notification, tokens) {
|
||||
if (tokens.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (deliveryMode === 'log-only') {
|
||||
return tokens.map((token) => ({
|
||||
tokenId: token.id,
|
||||
deliveryStatus: 'SIMULATED',
|
||||
provider: token.provider,
|
||||
providerMessageId: null,
|
||||
errorCode: null,
|
||||
errorMessage: null,
|
||||
responsePayload: {
|
||||
deliveryMode,
|
||||
},
|
||||
transient: false,
|
||||
}));
|
||||
}
|
||||
|
||||
const messages = tokens.map((token) => ({
|
||||
token: token.pushToken,
|
||||
notification: {
|
||||
title: notification.subject || 'Krow update',
|
||||
body: notification.body || '',
|
||||
},
|
||||
data: buildDataPayload(notification),
|
||||
android: {
|
||||
priority: mapPriority(notification.priority),
|
||||
},
|
||||
apns: {
|
||||
headers: {
|
||||
'apns-priority': mapPriority(notification.priority) === 'high' ? '10' : '5',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const dryRun = deliveryMode === 'dry-run';
|
||||
const response = await getFirebaseAdminMessaging().sendEach(messages, dryRun);
|
||||
return response.responses.map((item, index) => {
|
||||
const token = tokens[index];
|
||||
if (item.success) {
|
||||
return {
|
||||
tokenId: token.id,
|
||||
deliveryStatus: dryRun ? 'SIMULATED' : 'SENT',
|
||||
provider: token.provider,
|
||||
providerMessageId: item.messageId || null,
|
||||
errorCode: null,
|
||||
errorMessage: null,
|
||||
responsePayload: {
|
||||
deliveryMode,
|
||||
messageId: item.messageId || null,
|
||||
},
|
||||
transient: false,
|
||||
};
|
||||
}
|
||||
|
||||
const errorCode = item.error?.code || 'messaging/unknown-error';
|
||||
const errorMessage = item.error?.message || 'Push delivery failed';
|
||||
const classification = classifyMessagingError(errorCode);
|
||||
return {
|
||||
tokenId: token.id,
|
||||
deliveryStatus: classification === 'INVALID_TOKEN' ? 'INVALID_TOKEN' : 'FAILED',
|
||||
provider: token.provider,
|
||||
providerMessageId: null,
|
||||
errorCode,
|
||||
errorMessage,
|
||||
responsePayload: {
|
||||
deliveryMode,
|
||||
},
|
||||
transient: classification === 'RETRYABLE',
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
196
backend/command-api/src/services/notification-outbox.js
Normal file
196
backend/command-api/src/services/notification-outbox.js
Normal file
@@ -0,0 +1,196 @@
|
||||
export async function enqueueNotification(client, {
|
||||
tenantId,
|
||||
businessId = null,
|
||||
shiftId = null,
|
||||
assignmentId = null,
|
||||
relatedIncidentId = null,
|
||||
audienceType = 'USER',
|
||||
recipientUserId = null,
|
||||
recipientStaffId = null,
|
||||
recipientBusinessMembershipId = null,
|
||||
channel = 'PUSH',
|
||||
notificationType,
|
||||
priority = 'NORMAL',
|
||||
dedupeKey = null,
|
||||
subject = null,
|
||||
body = null,
|
||||
payload = {},
|
||||
scheduledAt = null,
|
||||
}) {
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO notification_outbox (
|
||||
tenant_id,
|
||||
business_id,
|
||||
shift_id,
|
||||
assignment_id,
|
||||
related_incident_id,
|
||||
audience_type,
|
||||
recipient_user_id,
|
||||
recipient_staff_id,
|
||||
recipient_business_membership_id,
|
||||
channel,
|
||||
notification_type,
|
||||
priority,
|
||||
dedupe_key,
|
||||
subject,
|
||||
body,
|
||||
payload,
|
||||
scheduled_at
|
||||
)
|
||||
VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16::jsonb, COALESCE($17::timestamptz, NOW())
|
||||
)
|
||||
ON CONFLICT (dedupe_key) DO NOTHING
|
||||
`,
|
||||
[
|
||||
tenantId,
|
||||
businessId,
|
||||
shiftId,
|
||||
assignmentId,
|
||||
relatedIncidentId,
|
||||
audienceType,
|
||||
recipientUserId,
|
||||
recipientStaffId,
|
||||
recipientBusinessMembershipId,
|
||||
channel,
|
||||
notificationType,
|
||||
priority,
|
||||
dedupeKey,
|
||||
subject,
|
||||
body,
|
||||
JSON.stringify(payload || {}),
|
||||
scheduledAt,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
async function loadHubNotificationRecipients(client, { tenantId, businessId, hubId }) {
|
||||
const scoped = await client.query(
|
||||
`
|
||||
SELECT DISTINCT
|
||||
hm.business_membership_id AS "businessMembershipId",
|
||||
bm.user_id AS "userId"
|
||||
FROM hub_managers hm
|
||||
JOIN business_memberships bm ON bm.id = hm.business_membership_id
|
||||
WHERE hm.tenant_id = $1
|
||||
AND hm.hub_id = $2
|
||||
AND bm.membership_status = 'ACTIVE'
|
||||
`,
|
||||
[tenantId, hubId]
|
||||
);
|
||||
|
||||
if (scoped.rowCount > 0) {
|
||||
return scoped.rows;
|
||||
}
|
||||
|
||||
const fallback = await client.query(
|
||||
`
|
||||
SELECT id AS "businessMembershipId", user_id AS "userId"
|
||||
FROM business_memberships
|
||||
WHERE tenant_id = $1
|
||||
AND business_id = $2
|
||||
AND membership_status = 'ACTIVE'
|
||||
AND business_role IN ('owner', 'manager')
|
||||
`,
|
||||
[tenantId, businessId]
|
||||
);
|
||||
return fallback.rows;
|
||||
}
|
||||
|
||||
export async function enqueueHubManagerAlert(client, {
|
||||
tenantId,
|
||||
businessId,
|
||||
shiftId = null,
|
||||
assignmentId = null,
|
||||
hubId = null,
|
||||
relatedIncidentId = null,
|
||||
notificationType,
|
||||
priority = 'HIGH',
|
||||
subject,
|
||||
body,
|
||||
payload = {},
|
||||
dedupeScope,
|
||||
}) {
|
||||
if (!hubId && !businessId) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const recipients = await loadHubNotificationRecipients(client, {
|
||||
tenantId,
|
||||
businessId,
|
||||
hubId,
|
||||
});
|
||||
|
||||
let createdCount = 0;
|
||||
for (const recipient of recipients) {
|
||||
const dedupeKey = [
|
||||
'notify',
|
||||
notificationType,
|
||||
dedupeScope || shiftId || assignmentId || relatedIncidentId || hubId || businessId,
|
||||
recipient.userId || recipient.businessMembershipId,
|
||||
].filter(Boolean).join(':');
|
||||
|
||||
await enqueueNotification(client, {
|
||||
tenantId,
|
||||
businessId,
|
||||
shiftId,
|
||||
assignmentId,
|
||||
relatedIncidentId,
|
||||
audienceType: recipient.userId ? 'USER' : 'BUSINESS_MEMBERSHIP',
|
||||
recipientUserId: recipient.userId || null,
|
||||
recipientBusinessMembershipId: recipient.businessMembershipId || null,
|
||||
channel: 'PUSH',
|
||||
notificationType,
|
||||
priority,
|
||||
dedupeKey,
|
||||
subject,
|
||||
body,
|
||||
payload,
|
||||
});
|
||||
createdCount += 1;
|
||||
}
|
||||
|
||||
return createdCount;
|
||||
}
|
||||
|
||||
export async function enqueueUserAlert(client, {
|
||||
tenantId,
|
||||
businessId = null,
|
||||
shiftId = null,
|
||||
assignmentId = null,
|
||||
relatedIncidentId = null,
|
||||
recipientUserId,
|
||||
notificationType,
|
||||
priority = 'NORMAL',
|
||||
subject = null,
|
||||
body = null,
|
||||
payload = {},
|
||||
dedupeScope,
|
||||
}) {
|
||||
if (!recipientUserId) return;
|
||||
|
||||
const dedupeKey = [
|
||||
'notify',
|
||||
notificationType,
|
||||
dedupeScope || shiftId || assignmentId || relatedIncidentId || recipientUserId,
|
||||
recipientUserId,
|
||||
].filter(Boolean).join(':');
|
||||
|
||||
await enqueueNotification(client, {
|
||||
tenantId,
|
||||
businessId,
|
||||
shiftId,
|
||||
assignmentId,
|
||||
relatedIncidentId,
|
||||
audienceType: 'USER',
|
||||
recipientUserId,
|
||||
channel: 'PUSH',
|
||||
notificationType,
|
||||
priority,
|
||||
dedupeKey,
|
||||
subject,
|
||||
body,
|
||||
payload,
|
||||
});
|
||||
}
|
||||
46
backend/command-api/src/worker-app.js
Normal file
46
backend/command-api/src/worker-app.js
Normal file
@@ -0,0 +1,46 @@
|
||||
import express from 'express';
|
||||
import pino from 'pino';
|
||||
import pinoHttp from 'pino-http';
|
||||
|
||||
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
|
||||
|
||||
export function createWorkerApp({ dispatch = async () => ({}) } = {}) {
|
||||
const app = express();
|
||||
|
||||
app.use(
|
||||
pinoHttp({
|
||||
logger,
|
||||
})
|
||||
);
|
||||
app.use(express.json({ limit: '256kb' }));
|
||||
|
||||
app.get('/health', (_req, res) => {
|
||||
res.status(200).json({ ok: true, service: 'notification-worker-v2' });
|
||||
});
|
||||
|
||||
app.get('/readyz', (_req, res) => {
|
||||
res.status(200).json({ ok: true, service: 'notification-worker-v2' });
|
||||
});
|
||||
|
||||
app.post('/tasks/dispatch-notifications', async (req, res) => {
|
||||
try {
|
||||
const summary = await dispatch();
|
||||
res.status(200).json({ ok: true, summary });
|
||||
} catch (error) {
|
||||
req.log?.error?.({ err: error }, 'notification dispatch failed');
|
||||
res.status(500).json({
|
||||
ok: false,
|
||||
error: error?.message || String(error),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.use((_req, res) => {
|
||||
res.status(404).json({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Route not found',
|
||||
});
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
12
backend/command-api/src/worker-server.js
Normal file
12
backend/command-api/src/worker-server.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { createWorkerApp } from './worker-app.js';
|
||||
import { dispatchPendingNotifications } from './services/notification-dispatcher.js';
|
||||
|
||||
const port = Number(process.env.PORT || 8080);
|
||||
const app = createWorkerApp({
|
||||
dispatch: () => dispatchPendingNotifications(),
|
||||
});
|
||||
|
||||
app.listen(port, () => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`krow-notification-worker listening on port ${port}`);
|
||||
});
|
||||
@@ -6,9 +6,42 @@ import { __resetIdempotencyStoreForTests } from '../src/services/idempotency-sto
|
||||
|
||||
process.env.AUTH_BYPASS = 'true';
|
||||
|
||||
const tenantId = '11111111-1111-4111-8111-111111111111';
|
||||
const businessId = '22222222-2222-4222-8222-222222222222';
|
||||
const shiftId = '33333333-3333-4333-8333-333333333333';
|
||||
|
||||
function validOrderCreatePayload() {
|
||||
return {
|
||||
tenantId,
|
||||
businessId,
|
||||
orderNumber: 'ORD-1001',
|
||||
title: 'Cafe Event Staffing',
|
||||
serviceType: 'EVENT',
|
||||
shifts: [
|
||||
{
|
||||
shiftCode: 'SHIFT-1',
|
||||
title: 'Morning Shift',
|
||||
startsAt: '2026-03-11T08:00:00.000Z',
|
||||
endsAt: '2026-03-11T16:00:00.000Z',
|
||||
requiredWorkers: 2,
|
||||
roles: [
|
||||
{
|
||||
roleCode: 'BARISTA',
|
||||
roleName: 'Barista',
|
||||
workersNeeded: 2,
|
||||
payRateCents: 2200,
|
||||
billRateCents: 3500,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.IDEMPOTENCY_STORE = 'memory';
|
||||
delete process.env.IDEMPOTENCY_DATABASE_URL;
|
||||
delete process.env.DATABASE_URL;
|
||||
__resetIdempotencyStoreForTests();
|
||||
});
|
||||
|
||||
@@ -21,34 +54,65 @@ test('GET /healthz returns healthy response', async () => {
|
||||
assert.equal(typeof res.body.requestId, 'string');
|
||||
});
|
||||
|
||||
test('GET /readyz reports database not configured when no database env is present', async () => {
|
||||
const app = createApp();
|
||||
const res = await request(app).get('/readyz');
|
||||
|
||||
assert.equal(res.status, 503);
|
||||
assert.equal(res.body.ok, false);
|
||||
assert.equal(res.body.status, 'DATABASE_NOT_CONFIGURED');
|
||||
});
|
||||
|
||||
test('command route requires idempotency key', async () => {
|
||||
const app = createApp();
|
||||
const res = await request(app)
|
||||
.post('/commands/orders/create')
|
||||
.set('Authorization', 'Bearer test-token')
|
||||
.send({ payload: {} });
|
||||
.send(validOrderCreatePayload());
|
||||
|
||||
assert.equal(res.status, 400);
|
||||
assert.equal(res.body.code, 'MISSING_IDEMPOTENCY_KEY');
|
||||
});
|
||||
|
||||
test('command route is idempotent by key', async () => {
|
||||
const app = createApp();
|
||||
test('command route is idempotent by key and only executes handler once', async () => {
|
||||
let callCount = 0;
|
||||
const app = createApp({
|
||||
commandHandlers: {
|
||||
createOrder: async () => {
|
||||
callCount += 1;
|
||||
return {
|
||||
orderId: '44444444-4444-4444-8444-444444444444',
|
||||
orderNumber: 'ORD-1001',
|
||||
status: 'OPEN',
|
||||
shiftCount: 1,
|
||||
shiftIds: [shiftId],
|
||||
};
|
||||
},
|
||||
acceptShift: async () => assert.fail('acceptShift should not be called'),
|
||||
clockIn: async () => assert.fail('clockIn should not be called'),
|
||||
clockOut: async () => assert.fail('clockOut should not be called'),
|
||||
addFavoriteStaff: async () => assert.fail('addFavoriteStaff should not be called'),
|
||||
removeFavoriteStaff: async () => assert.fail('removeFavoriteStaff should not be called'),
|
||||
createStaffReview: async () => assert.fail('createStaffReview should not be called'),
|
||||
},
|
||||
});
|
||||
|
||||
const first = await request(app)
|
||||
.post('/commands/orders/create')
|
||||
.set('Authorization', 'Bearer test-token')
|
||||
.set('Idempotency-Key', 'abc-123')
|
||||
.send({ payload: { order: 'x' } });
|
||||
.send(validOrderCreatePayload());
|
||||
|
||||
const second = await request(app)
|
||||
.post('/commands/orders/create')
|
||||
.set('Authorization', 'Bearer test-token')
|
||||
.set('Idempotency-Key', 'abc-123')
|
||||
.send({ payload: { order: 'x' } });
|
||||
.send(validOrderCreatePayload());
|
||||
|
||||
assert.equal(first.status, 200);
|
||||
assert.equal(second.status, 200);
|
||||
assert.equal(first.body.commandId, second.body.commandId);
|
||||
assert.equal(callCount, 1);
|
||||
assert.equal(first.body.orderId, second.body.orderId);
|
||||
assert.equal(first.body.idempotencyKey, 'abc-123');
|
||||
assert.equal(second.body.idempotencyKey, 'abc-123');
|
||||
});
|
||||
|
||||
344
backend/command-api/test/mobile-routes.test.js
Normal file
344
backend/command-api/test/mobile-routes.test.js
Normal file
@@ -0,0 +1,344 @@
|
||||
import test, { beforeEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../src/app.js';
|
||||
import { __resetIdempotencyStoreForTests } from '../src/services/idempotency-store.js';
|
||||
|
||||
process.env.AUTH_BYPASS = 'true';
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.IDEMPOTENCY_STORE = 'memory';
|
||||
delete process.env.IDEMPOTENCY_DATABASE_URL;
|
||||
delete process.env.DATABASE_URL;
|
||||
__resetIdempotencyStoreForTests();
|
||||
});
|
||||
|
||||
function createMobileHandlers() {
|
||||
return {
|
||||
createClientOneTimeOrder: async (_actor, payload) => ({
|
||||
orderId: 'order-1',
|
||||
orderType: 'ONE_TIME',
|
||||
eventName: payload.eventName,
|
||||
}),
|
||||
createClientRecurringOrder: async (_actor, payload) => ({
|
||||
orderId: 'order-2',
|
||||
orderType: 'RECURRING',
|
||||
recurrenceDays: payload.recurrenceDays,
|
||||
}),
|
||||
createClientPermanentOrder: async (_actor, payload) => ({
|
||||
orderId: 'order-3',
|
||||
orderType: 'PERMANENT',
|
||||
horizonDays: payload.horizonDays || 28,
|
||||
}),
|
||||
createEditedOrderCopy: async (_actor, payload) => ({
|
||||
sourceOrderId: payload.orderId,
|
||||
orderId: 'order-4',
|
||||
cloned: true,
|
||||
}),
|
||||
cancelClientOrder: async (_actor, payload) => ({
|
||||
orderId: payload.orderId,
|
||||
status: 'CANCELLED',
|
||||
}),
|
||||
createHub: async (_actor, payload) => ({
|
||||
hubId: 'hub-1',
|
||||
name: payload.name,
|
||||
costCenterId: payload.costCenterId,
|
||||
}),
|
||||
approveInvoice: async (_actor, payload) => ({
|
||||
invoiceId: payload.invoiceId,
|
||||
status: 'APPROVED',
|
||||
}),
|
||||
registerClientPushToken: async (_actor, payload) => ({
|
||||
tokenId: 'push-token-client-1',
|
||||
platform: payload.platform,
|
||||
notificationsEnabled: payload.notificationsEnabled ?? true,
|
||||
}),
|
||||
unregisterClientPushToken: async () => ({
|
||||
removedCount: 1,
|
||||
}),
|
||||
applyForShift: async (_actor, payload) => ({
|
||||
shiftId: payload.shiftId,
|
||||
status: 'APPLIED',
|
||||
}),
|
||||
registerStaffPushToken: async (_actor, payload) => ({
|
||||
tokenId: 'push-token-staff-1',
|
||||
platform: payload.platform,
|
||||
notificationsEnabled: payload.notificationsEnabled ?? true,
|
||||
}),
|
||||
unregisterStaffPushToken: async () => ({
|
||||
removedCount: 1,
|
||||
}),
|
||||
staffClockIn: async (_actor, payload) => ({
|
||||
assignmentId: payload.assignmentId || 'assignment-1',
|
||||
status: 'CLOCK_IN',
|
||||
proofNonce: payload.proofNonce || null,
|
||||
}),
|
||||
staffClockOut: async (_actor, payload) => ({
|
||||
assignmentId: payload.assignmentId || 'assignment-1',
|
||||
status: 'CLOCK_OUT',
|
||||
}),
|
||||
submitLocationStreamBatch: async (_actor, payload) => ({
|
||||
assignmentId: payload.assignmentId || 'assignment-1',
|
||||
pointCount: payload.points.length,
|
||||
status: 'RECORDED',
|
||||
}),
|
||||
saveTaxFormDraft: async (_actor, payload) => ({
|
||||
formType: payload.formType,
|
||||
status: 'DRAFT',
|
||||
}),
|
||||
addStaffBankAccount: async (_actor, payload) => ({
|
||||
accountType: payload.accountType,
|
||||
last4: payload.accountNumber.slice(-4),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
test('POST /commands/client/orders/one-time forwards one-time order payload', async () => {
|
||||
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
|
||||
const res = await request(app)
|
||||
.post('/commands/client/orders/one-time')
|
||||
.set('Authorization', 'Bearer test-token')
|
||||
.set('Idempotency-Key', 'client-order-1')
|
||||
.send({
|
||||
hubId: '11111111-1111-4111-8111-111111111111',
|
||||
vendorId: '22222222-2222-4222-8222-222222222222',
|
||||
eventName: 'Google Cafe Coverage',
|
||||
orderDate: '2026-03-20',
|
||||
positions: [
|
||||
{
|
||||
roleId: '33333333-3333-4333-8333-333333333333',
|
||||
startTime: '09:00',
|
||||
endTime: '17:00',
|
||||
workerCount: 2,
|
||||
hourlyRateCents: 2800,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.orderId, 'order-1');
|
||||
assert.equal(res.body.orderType, 'ONE_TIME');
|
||||
assert.equal(res.body.eventName, 'Google Cafe Coverage');
|
||||
});
|
||||
|
||||
test('POST /commands/client/orders/:orderId/edit injects order id from params', async () => {
|
||||
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
|
||||
const res = await request(app)
|
||||
.post('/commands/client/orders/44444444-4444-4444-8444-444444444444/edit')
|
||||
.set('Authorization', 'Bearer test-token')
|
||||
.set('Idempotency-Key', 'client-order-edit-1')
|
||||
.send({
|
||||
eventName: 'Edited Order Copy',
|
||||
});
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.sourceOrderId, '44444444-4444-4444-8444-444444444444');
|
||||
assert.equal(res.body.cloned, true);
|
||||
});
|
||||
|
||||
test('POST /commands/client/hubs returns injected hub response', async () => {
|
||||
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
|
||||
const res = await request(app)
|
||||
.post('/commands/client/hubs')
|
||||
.set('Authorization', 'Bearer test-token')
|
||||
.set('Idempotency-Key', 'hub-create-1')
|
||||
.send({
|
||||
tenantId: '11111111-1111-4111-8111-111111111111',
|
||||
businessId: '22222222-2222-4222-8222-222222222222',
|
||||
name: 'Google North Hub',
|
||||
locationName: 'North Campus',
|
||||
timezone: 'America/Los_Angeles',
|
||||
latitude: 37.422,
|
||||
longitude: -122.084,
|
||||
geofenceRadiusMeters: 100,
|
||||
clockInMode: 'GEO_REQUIRED',
|
||||
allowClockInOverride: true,
|
||||
costCenterId: '44444444-4444-4444-8444-444444444444',
|
||||
});
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.hubId, 'hub-1');
|
||||
assert.equal(res.body.name, 'Google North Hub');
|
||||
});
|
||||
|
||||
test('POST /commands/client/billing/invoices/:invoiceId/approve injects invoice id from params', async () => {
|
||||
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
|
||||
const res = await request(app)
|
||||
.post('/commands/client/billing/invoices/55555555-5555-4555-8555-555555555555/approve')
|
||||
.set('Authorization', 'Bearer test-token')
|
||||
.set('Idempotency-Key', 'invoice-approve-1')
|
||||
.send({});
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.invoiceId, '55555555-5555-4555-8555-555555555555');
|
||||
assert.equal(res.body.status, 'APPROVED');
|
||||
});
|
||||
|
||||
test('POST /commands/client/devices/push-tokens registers a client push token', async () => {
|
||||
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
|
||||
const res = await request(app)
|
||||
.post('/commands/client/devices/push-tokens')
|
||||
.set('Authorization', 'Bearer test-token')
|
||||
.set('Idempotency-Key', 'client-push-token-1')
|
||||
.send({
|
||||
provider: 'FCM',
|
||||
platform: 'IOS',
|
||||
pushToken: 'f'.repeat(160),
|
||||
deviceId: 'iphone-15-pro',
|
||||
notificationsEnabled: true,
|
||||
});
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.tokenId, 'push-token-client-1');
|
||||
assert.equal(res.body.platform, 'IOS');
|
||||
});
|
||||
|
||||
test('DELETE /commands/client/devices/push-tokens accepts tokenId from query params', async () => {
|
||||
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
|
||||
const res = await request(app)
|
||||
.delete('/commands/client/devices/push-tokens?tokenId=11111111-1111-4111-8111-111111111111&reason=SMOKE_CLEANUP')
|
||||
.set('Authorization', 'Bearer test-token')
|
||||
.set('Idempotency-Key', 'client-push-token-delete-1');
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.removedCount, 1);
|
||||
});
|
||||
|
||||
test('POST /commands/staff/shifts/:shiftId/apply injects shift id from params', async () => {
|
||||
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
|
||||
const res = await request(app)
|
||||
.post('/commands/staff/shifts/66666666-6666-4666-8666-666666666666/apply')
|
||||
.set('Authorization', 'Bearer test-token')
|
||||
.set('Idempotency-Key', 'shift-apply-1')
|
||||
.send({
|
||||
note: 'Available tonight',
|
||||
});
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.shiftId, '66666666-6666-4666-8666-666666666666');
|
||||
assert.equal(res.body.status, 'APPLIED');
|
||||
});
|
||||
|
||||
test('POST /commands/staff/clock-in accepts shift-based payload', async () => {
|
||||
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
|
||||
const res = await request(app)
|
||||
.post('/commands/staff/clock-in')
|
||||
.set('Authorization', 'Bearer test-token')
|
||||
.set('Idempotency-Key', 'clock-in-1')
|
||||
.send({
|
||||
shiftId: '77777777-7777-4777-8777-777777777777',
|
||||
sourceType: 'GEO',
|
||||
latitude: 37.422,
|
||||
longitude: -122.084,
|
||||
proofNonce: 'nonce-12345678',
|
||||
overrideReason: 'GPS timed out near the hub',
|
||||
});
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.status, 'CLOCK_IN');
|
||||
assert.equal(res.body.proofNonce, 'nonce-12345678');
|
||||
});
|
||||
|
||||
test('POST /commands/staff/clock-out accepts assignment-based payload', async () => {
|
||||
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
|
||||
const res = await request(app)
|
||||
.post('/commands/staff/clock-out')
|
||||
.set('Authorization', 'Bearer test-token')
|
||||
.set('Idempotency-Key', 'clock-out-1')
|
||||
.send({
|
||||
assignmentId: '88888888-8888-4888-8888-888888888888',
|
||||
breakMinutes: 30,
|
||||
});
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.status, 'CLOCK_OUT');
|
||||
});
|
||||
|
||||
test('POST /commands/staff/location-streams accepts batched location payloads', async () => {
|
||||
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
|
||||
const res = await request(app)
|
||||
.post('/commands/staff/location-streams')
|
||||
.set('Authorization', 'Bearer test-token')
|
||||
.set('Idempotency-Key', 'location-stream-1')
|
||||
.send({
|
||||
assignmentId: '99999999-9999-4999-8999-999999999999',
|
||||
sourceType: 'GEO',
|
||||
deviceId: 'iphone-15',
|
||||
points: [
|
||||
{
|
||||
capturedAt: '2026-03-16T08:00:00.000Z',
|
||||
latitude: 37.422,
|
||||
longitude: -122.084,
|
||||
accuracyMeters: 12,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.status, 'RECORDED');
|
||||
assert.equal(res.body.pointCount, 1);
|
||||
});
|
||||
|
||||
test('POST /commands/staff/devices/push-tokens registers a staff push token', async () => {
|
||||
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
|
||||
const res = await request(app)
|
||||
.post('/commands/staff/devices/push-tokens')
|
||||
.set('Authorization', 'Bearer test-token')
|
||||
.set('Idempotency-Key', 'staff-push-token-1')
|
||||
.send({
|
||||
provider: 'FCM',
|
||||
platform: 'ANDROID',
|
||||
pushToken: 'g'.repeat(170),
|
||||
deviceId: 'pixel-9',
|
||||
});
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.tokenId, 'push-token-staff-1');
|
||||
assert.equal(res.body.platform, 'ANDROID');
|
||||
});
|
||||
|
||||
test('DELETE /commands/staff/devices/push-tokens accepts tokenId from query params', async () => {
|
||||
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
|
||||
const res = await request(app)
|
||||
.delete('/commands/staff/devices/push-tokens?tokenId=22222222-2222-4222-8222-222222222222&reason=SMOKE_CLEANUP')
|
||||
.set('Authorization', 'Bearer test-token')
|
||||
.set('Idempotency-Key', 'staff-push-token-delete-1');
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.removedCount, 1);
|
||||
});
|
||||
|
||||
test('PUT /commands/staff/profile/tax-forms/:formType uppercases form type', async () => {
|
||||
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
|
||||
const res = await request(app)
|
||||
.put('/commands/staff/profile/tax-forms/w4')
|
||||
.set('Authorization', 'Bearer test-token')
|
||||
.set('Idempotency-Key', 'tax-form-1')
|
||||
.send({
|
||||
fields: {
|
||||
filingStatus: 'single',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.formType, 'W4');
|
||||
assert.equal(res.body.status, 'DRAFT');
|
||||
});
|
||||
|
||||
test('POST /commands/staff/profile/bank-accounts uppercases account type', async () => {
|
||||
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
|
||||
const res = await request(app)
|
||||
.post('/commands/staff/profile/bank-accounts')
|
||||
.set('Authorization', 'Bearer test-token')
|
||||
.set('Idempotency-Key', 'bank-account-1')
|
||||
.send({
|
||||
bankName: 'Demo Credit Union',
|
||||
accountNumber: '1234567890',
|
||||
routingNumber: '021000021',
|
||||
accountType: 'checking',
|
||||
});
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.accountType, 'CHECKING');
|
||||
assert.equal(res.body.last4, '7890');
|
||||
});
|
||||
38
backend/command-api/test/notification-dispatcher.test.js
Normal file
38
backend/command-api/test/notification-dispatcher.test.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { computeRetryDelayMinutes } from '../src/services/notification-dispatcher.js';
|
||||
import { createPushSender, classifyMessagingError } from '../src/services/notification-fcm.js';
|
||||
|
||||
test('computeRetryDelayMinutes backs off exponentially with a cap', () => {
|
||||
assert.equal(computeRetryDelayMinutes(1), 5);
|
||||
assert.equal(computeRetryDelayMinutes(2), 10);
|
||||
assert.equal(computeRetryDelayMinutes(3), 20);
|
||||
assert.equal(computeRetryDelayMinutes(5), 60);
|
||||
assert.equal(computeRetryDelayMinutes(9), 60);
|
||||
});
|
||||
|
||||
test('classifyMessagingError distinguishes invalid and retryable push failures', () => {
|
||||
assert.equal(classifyMessagingError('messaging/registration-token-not-registered'), 'INVALID_TOKEN');
|
||||
assert.equal(classifyMessagingError('messaging/server-unavailable'), 'RETRYABLE');
|
||||
assert.equal(classifyMessagingError('messaging/unknown-problem'), 'FAILED');
|
||||
});
|
||||
|
||||
test('createPushSender log-only mode simulates successful delivery results', async () => {
|
||||
const sender = createPushSender({ deliveryMode: 'log-only' });
|
||||
const results = await sender.send(
|
||||
{
|
||||
id: 'notification-1',
|
||||
notification_type: 'SHIFT_START_REMINDER',
|
||||
priority: 'HIGH',
|
||||
tenant_id: 'tenant-1',
|
||||
payload: { assignmentId: 'assignment-1' },
|
||||
},
|
||||
[
|
||||
{ id: 'token-1', provider: 'FCM', pushToken: 'demo-token' },
|
||||
]
|
||||
);
|
||||
|
||||
assert.equal(results.length, 1);
|
||||
assert.equal(results[0].deliveryStatus, 'SIMULATED');
|
||||
assert.equal(results[0].transient, false);
|
||||
});
|
||||
47
backend/command-api/test/notification-worker.test.js
Normal file
47
backend/command-api/test/notification-worker.test.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import request from 'supertest';
|
||||
import { createWorkerApp } from '../src/worker-app.js';
|
||||
|
||||
test('GET /readyz returns healthy response', async () => {
|
||||
const app = createWorkerApp();
|
||||
const res = await request(app).get('/readyz');
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.ok, true);
|
||||
assert.equal(res.body.service, 'notification-worker-v2');
|
||||
});
|
||||
|
||||
test('POST /tasks/dispatch-notifications returns dispatch summary', async () => {
|
||||
const app = createWorkerApp({
|
||||
dispatch: async () => ({
|
||||
claimed: 2,
|
||||
sent: 2,
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post('/tasks/dispatch-notifications')
|
||||
.send({});
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.ok, true);
|
||||
assert.equal(res.body.summary.claimed, 2);
|
||||
assert.equal(res.body.summary.sent, 2);
|
||||
});
|
||||
|
||||
test('POST /tasks/dispatch-notifications returns 500 on dispatch error', async () => {
|
||||
const app = createWorkerApp({
|
||||
dispatch: async () => {
|
||||
throw new Error('dispatch exploded');
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post('/tasks/dispatch-notifications')
|
||||
.send({});
|
||||
|
||||
assert.equal(res.status, 500);
|
||||
assert.equal(res.body.ok, false);
|
||||
assert.match(res.body.error, /dispatch exploded/);
|
||||
});
|
||||
129
backend/core-api/package-lock.json
generated
129
backend/core-api/package-lock.json
generated
@@ -13,6 +13,7 @@
|
||||
"firebase-admin": "^13.0.2",
|
||||
"google-auth-library": "^9.15.1",
|
||||
"multer": "^2.0.2",
|
||||
"pg": "^8.20.0",
|
||||
"pino": "^9.6.0",
|
||||
"pino-http": "^10.3.0",
|
||||
"zod": "^3.24.2"
|
||||
@@ -2037,6 +2038,95 @@
|
||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
|
||||
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.12.0",
|
||||
"pg-pool": "^3.13.0",
|
||||
"pg-protocol": "^1.13.0",
|
||||
"pg-types": "2.2.0",
|
||||
"pgpass": "1.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"pg-cloudflare": "^1.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pg-native": ">=3.0.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"pg-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pg-cloudflare": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
|
||||
"integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/pg-connection-string": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz",
|
||||
"integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-int8": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-pool": {
|
||||
"version": "3.13.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz",
|
||||
"integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"pg": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-protocol": {
|
||||
"version": "1.13.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz",
|
||||
"integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-types": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-int8": "1.0.1",
|
||||
"postgres-array": "~2.0.0",
|
||||
"postgres-bytea": "~1.0.0",
|
||||
"postgres-date": "~1.0.4",
|
||||
"postgres-interval": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/pgpass": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pino": {
|
||||
"version": "9.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz",
|
||||
@@ -2086,6 +2176,45 @@
|
||||
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/postgres-array": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-bytea": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-date": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-interval": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xtend": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/process-warning": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"firebase-admin": "^13.0.2",
|
||||
"google-auth-library": "^9.15.1",
|
||||
"multer": "^2.0.2",
|
||||
"pg": "^8.20.0",
|
||||
"pino": "^9.6.0",
|
||||
"pino-http": "^10.3.0",
|
||||
"zod": "^3.24.2"
|
||||
|
||||
@@ -24,6 +24,12 @@ import {
|
||||
retryVerificationJob,
|
||||
reviewVerificationJob,
|
||||
} from '../services/verification-jobs.js';
|
||||
import {
|
||||
deleteCertificate,
|
||||
uploadCertificate,
|
||||
uploadProfilePhoto,
|
||||
uploadStaffDocument,
|
||||
} from '../services/mobile-upload.js';
|
||||
|
||||
const DEFAULT_MAX_FILE_BYTES = 10 * 1024 * 1024;
|
||||
const DEFAULT_MAX_SIGNED_URL_SECONDS = 900;
|
||||
@@ -56,6 +62,14 @@ const uploadMetaSchema = z.object({
|
||||
visibility: z.enum(['public', 'private']).optional(),
|
||||
});
|
||||
|
||||
const certificateUploadMetaSchema = z.object({
|
||||
certificateType: z.string().min(1).max(120),
|
||||
name: z.string().min(1).max(160),
|
||||
issuer: z.string().max(160).optional(),
|
||||
certificateNumber: z.string().max(160).optional(),
|
||||
expiresAt: z.string().datetime().optional(),
|
||||
});
|
||||
|
||||
function mockSignedUrl(fileUri, expiresInSeconds) {
|
||||
const encoded = encodeURIComponent(fileUri);
|
||||
const expiresAt = new Date(Date.now() + expiresInSeconds * 1000).toISOString();
|
||||
@@ -292,7 +306,7 @@ async function handleCreateVerification(req, res, next) {
|
||||
});
|
||||
}
|
||||
|
||||
const created = createVerificationJob({
|
||||
const created = await createVerificationJob({
|
||||
actorUid: req.actor.uid,
|
||||
payload,
|
||||
});
|
||||
@@ -305,10 +319,107 @@ async function handleCreateVerification(req, res, next) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleProfilePhotoUpload(req, res, next) {
|
||||
try {
|
||||
const file = req.file;
|
||||
if (!file) {
|
||||
throw new AppError('INVALID_FILE', 'Missing file in multipart form data', 400);
|
||||
}
|
||||
const result = await uploadProfilePhoto({
|
||||
actorUid: req.actor.uid,
|
||||
file,
|
||||
});
|
||||
return res.status(200).json({
|
||||
...result,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDocumentUpload(req, res, next) {
|
||||
try {
|
||||
const file = req.file;
|
||||
if (!file) {
|
||||
throw new AppError('INVALID_FILE', 'Missing file in multipart form data', 400);
|
||||
}
|
||||
const result = await uploadStaffDocument({
|
||||
actorUid: req.actor.uid,
|
||||
documentId: req.params.documentId,
|
||||
file,
|
||||
routeType: 'document',
|
||||
});
|
||||
return res.status(200).json({
|
||||
...result,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAttireUpload(req, res, next) {
|
||||
try {
|
||||
const file = req.file;
|
||||
if (!file) {
|
||||
throw new AppError('INVALID_FILE', 'Missing file in multipart form data', 400);
|
||||
}
|
||||
const result = await uploadStaffDocument({
|
||||
actorUid: req.actor.uid,
|
||||
documentId: req.params.documentId,
|
||||
file,
|
||||
routeType: 'attire',
|
||||
});
|
||||
return res.status(200).json({
|
||||
...result,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCertificateUpload(req, res, next) {
|
||||
try {
|
||||
const file = req.file;
|
||||
if (!file) {
|
||||
throw new AppError('INVALID_FILE', 'Missing file in multipart form data', 400);
|
||||
}
|
||||
const payload = parseBody(certificateUploadMetaSchema, req.body || {});
|
||||
const result = await uploadCertificate({
|
||||
actorUid: req.actor.uid,
|
||||
file,
|
||||
payload,
|
||||
});
|
||||
return res.status(200).json({
|
||||
...result,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCertificateDelete(req, res, next) {
|
||||
try {
|
||||
const result = await deleteCertificate({
|
||||
actorUid: req.actor.uid,
|
||||
certificateType: req.params.certificateType,
|
||||
});
|
||||
return res.status(200).json({
|
||||
...result,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGetVerification(req, res, next) {
|
||||
try {
|
||||
const verificationId = req.params.verificationId;
|
||||
const job = getVerificationJob(verificationId, req.actor.uid);
|
||||
const job = await getVerificationJob(verificationId, req.actor.uid);
|
||||
return res.status(200).json({
|
||||
...job,
|
||||
requestId: req.requestId,
|
||||
@@ -322,7 +433,7 @@ async function handleReviewVerification(req, res, next) {
|
||||
try {
|
||||
const verificationId = req.params.verificationId;
|
||||
const payload = parseBody(reviewVerificationSchema, req.body || {});
|
||||
const updated = reviewVerificationJob(verificationId, req.actor.uid, payload);
|
||||
const updated = await reviewVerificationJob(verificationId, req.actor.uid, payload);
|
||||
return res.status(200).json({
|
||||
...updated,
|
||||
requestId: req.requestId,
|
||||
@@ -335,7 +446,7 @@ async function handleReviewVerification(req, res, next) {
|
||||
async function handleRetryVerification(req, res, next) {
|
||||
try {
|
||||
const verificationId = req.params.verificationId;
|
||||
const updated = retryVerificationJob(verificationId, req.actor.uid);
|
||||
const updated = await retryVerificationJob(verificationId, req.actor.uid);
|
||||
return res.status(202).json({
|
||||
...updated,
|
||||
requestId: req.requestId,
|
||||
@@ -353,6 +464,11 @@ export function createCoreRouter() {
|
||||
router.post('/invoke-llm', requireAuth, requirePolicy('core.invoke-llm', 'model'), handleInvokeLlm);
|
||||
router.post('/rapid-orders/transcribe', requireAuth, requirePolicy('core.rapid-order.transcribe', 'model'), handleRapidOrderTranscribe);
|
||||
router.post('/rapid-orders/parse', requireAuth, requirePolicy('core.rapid-order.parse', 'model'), handleRapidOrderParse);
|
||||
router.post('/staff/profile/photo', requireAuth, requirePolicy('core.upload', 'file'), upload.single('file'), handleProfilePhotoUpload);
|
||||
router.post('/staff/documents/:documentId/upload', requireAuth, requirePolicy('core.upload', 'file'), upload.single('file'), handleDocumentUpload);
|
||||
router.post('/staff/attire/:documentId/upload', requireAuth, requirePolicy('core.upload', 'file'), upload.single('file'), handleAttireUpload);
|
||||
router.post('/staff/certificates/upload', requireAuth, requirePolicy('core.upload', 'file'), upload.single('file'), handleCertificateUpload);
|
||||
router.delete('/staff/certificates/:certificateType', requireAuth, requirePolicy('core.upload', 'file'), handleCertificateDelete);
|
||||
router.post('/verifications', requireAuth, requirePolicy('core.verification.create', 'verification'), handleCreateVerification);
|
||||
router.get('/verifications/:verificationId', requireAuth, requirePolicy('core.verification.read', 'verification'), handleGetVerification);
|
||||
router.post('/verifications/:verificationId/review', requireAuth, requirePolicy('core.verification.review', 'verification'), handleReviewVerification);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Router } from 'express';
|
||||
import { checkDatabaseHealth, isDatabaseConfigured } from '../services/db.js';
|
||||
|
||||
export const healthRouter = Router();
|
||||
|
||||
@@ -13,3 +14,31 @@ function healthHandler(req, res) {
|
||||
|
||||
healthRouter.get('/health', healthHandler);
|
||||
healthRouter.get('/healthz', healthHandler);
|
||||
|
||||
healthRouter.get('/readyz', async (req, res) => {
|
||||
if (!isDatabaseConfigured()) {
|
||||
return res.status(503).json({
|
||||
ok: false,
|
||||
service: 'krow-core-api',
|
||||
status: 'DATABASE_NOT_CONFIGURED',
|
||||
requestId: req.requestId,
|
||||
});
|
||||
}
|
||||
|
||||
const healthy = await checkDatabaseHealth().catch(() => false);
|
||||
if (!healthy) {
|
||||
return res.status(503).json({
|
||||
ok: false,
|
||||
service: 'krow-core-api',
|
||||
status: 'DATABASE_UNAVAILABLE',
|
||||
requestId: req.requestId,
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
ok: true,
|
||||
service: 'krow-core-api',
|
||||
status: 'READY',
|
||||
requestId: req.requestId,
|
||||
});
|
||||
});
|
||||
|
||||
67
backend/core-api/src/services/actor-context.js
Normal file
67
backend/core-api/src/services/actor-context.js
Normal file
@@ -0,0 +1,67 @@
|
||||
import { AppError } from '../lib/errors.js';
|
||||
import { query } from './db.js';
|
||||
|
||||
export async function loadActorContext(uid) {
|
||||
const [userResult, tenantResult, staffResult] = await Promise.all([
|
||||
query(
|
||||
`
|
||||
SELECT id AS "userId", email, display_name AS "displayName", phone, status
|
||||
FROM users
|
||||
WHERE id = $1
|
||||
`,
|
||||
[uid]
|
||||
),
|
||||
query(
|
||||
`
|
||||
SELECT tm.id AS "membershipId",
|
||||
tm.tenant_id AS "tenantId",
|
||||
tm.base_role AS role,
|
||||
t.name AS "tenantName",
|
||||
t.slug AS "tenantSlug"
|
||||
FROM tenant_memberships tm
|
||||
JOIN tenants t ON t.id = tm.tenant_id
|
||||
WHERE tm.user_id = $1
|
||||
AND tm.membership_status = 'ACTIVE'
|
||||
ORDER BY tm.created_at ASC
|
||||
LIMIT 1
|
||||
`,
|
||||
[uid]
|
||||
),
|
||||
query(
|
||||
`
|
||||
SELECT s.id AS "staffId",
|
||||
s.tenant_id AS "tenantId",
|
||||
s.full_name AS "fullName",
|
||||
s.status,
|
||||
s.metadata
|
||||
FROM staffs s
|
||||
WHERE s.user_id = $1
|
||||
ORDER BY s.created_at ASC
|
||||
LIMIT 1
|
||||
`,
|
||||
[uid]
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
user: userResult.rows[0] || null,
|
||||
tenant: tenantResult.rows[0] || null,
|
||||
staff: staffResult.rows[0] || null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function requireTenantContext(uid) {
|
||||
const context = await loadActorContext(uid);
|
||||
if (!context.user || !context.tenant) {
|
||||
throw new AppError('FORBIDDEN', 'Tenant context is required for this route', 403, { uid });
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export async function requireStaffContext(uid) {
|
||||
const context = await loadActorContext(uid);
|
||||
if (!context.user || !context.tenant || !context.staff) {
|
||||
throw new AppError('FORBIDDEN', 'Staff context is required for this route', 403, { uid });
|
||||
}
|
||||
return context;
|
||||
}
|
||||
98
backend/core-api/src/services/db.js
Normal file
98
backend/core-api/src/services/db.js
Normal file
@@ -0,0 +1,98 @@
|
||||
import pg from 'pg';
|
||||
|
||||
const { Pool, types } = pg;
|
||||
|
||||
function parseNumericDatabaseValue(value) {
|
||||
if (value == null) return value;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : value;
|
||||
}
|
||||
|
||||
types.setTypeParser(types.builtins.INT8, parseNumericDatabaseValue);
|
||||
types.setTypeParser(types.builtins.NUMERIC, parseNumericDatabaseValue);
|
||||
|
||||
let pool;
|
||||
|
||||
function parseIntOrDefault(value, fallback) {
|
||||
const parsed = Number.parseInt(`${value || fallback}`, 10);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function resolveDatabasePoolConfig() {
|
||||
if (process.env.DATABASE_URL) {
|
||||
return {
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
max: parseIntOrDefault(process.env.DB_POOL_MAX, 10),
|
||||
idleTimeoutMillis: parseIntOrDefault(process.env.DB_IDLE_TIMEOUT_MS, 30000),
|
||||
};
|
||||
}
|
||||
|
||||
const user = process.env.DB_USER;
|
||||
const password = process.env.DB_PASSWORD;
|
||||
const database = process.env.DB_NAME;
|
||||
const host = process.env.DB_HOST || (
|
||||
process.env.INSTANCE_CONNECTION_NAME
|
||||
? `/cloudsql/${process.env.INSTANCE_CONNECTION_NAME}`
|
||||
: ''
|
||||
);
|
||||
|
||||
if (!user || password == null || !database || !host) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
host,
|
||||
port: parseIntOrDefault(process.env.DB_PORT, 5432),
|
||||
user,
|
||||
password,
|
||||
database,
|
||||
max: parseIntOrDefault(process.env.DB_POOL_MAX, 10),
|
||||
idleTimeoutMillis: parseIntOrDefault(process.env.DB_IDLE_TIMEOUT_MS, 30000),
|
||||
};
|
||||
}
|
||||
|
||||
export function isDatabaseConfigured() {
|
||||
return Boolean(resolveDatabasePoolConfig());
|
||||
}
|
||||
|
||||
function getPool() {
|
||||
if (!pool) {
|
||||
const resolved = resolveDatabasePoolConfig();
|
||||
if (!resolved) {
|
||||
throw new Error('Database connection settings are required');
|
||||
}
|
||||
pool = new Pool(resolved);
|
||||
}
|
||||
return pool;
|
||||
}
|
||||
|
||||
export async function query(text, params = []) {
|
||||
return getPool().query(text, params);
|
||||
}
|
||||
|
||||
export async function withTransaction(work) {
|
||||
const client = await getPool().connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const result = await work(client);
|
||||
await client.query('COMMIT');
|
||||
return result;
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkDatabaseHealth() {
|
||||
const result = await query('SELECT 1 AS ok');
|
||||
return result.rows[0]?.ok === 1;
|
||||
}
|
||||
|
||||
export async function closePool() {
|
||||
if (pool) {
|
||||
await pool.end();
|
||||
pool = null;
|
||||
}
|
||||
}
|
||||
260
backend/core-api/src/services/mobile-upload.js
Normal file
260
backend/core-api/src/services/mobile-upload.js
Normal file
@@ -0,0 +1,260 @@
|
||||
import { AppError } from '../lib/errors.js';
|
||||
import { requireStaffContext } from './actor-context.js';
|
||||
import { generateReadSignedUrl, uploadToGcs } from './storage.js';
|
||||
import { query, withTransaction } from './db.js';
|
||||
import { createVerificationJob } from './verification-jobs.js';
|
||||
|
||||
function safeName(value) {
|
||||
return `${value}`.replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
}
|
||||
|
||||
function uploadBucket() {
|
||||
return process.env.PRIVATE_BUCKET || 'krow-workforce-dev-private';
|
||||
}
|
||||
|
||||
async function uploadActorFile({ actorUid, file, category }) {
|
||||
const bucket = uploadBucket();
|
||||
const objectPath = `uploads/${actorUid}/${category}/${Date.now()}_${safeName(file.originalname)}`;
|
||||
const fileUri = `gs://${bucket}/${objectPath}`;
|
||||
await uploadToGcs({
|
||||
bucket,
|
||||
objectPath,
|
||||
contentType: file.mimetype,
|
||||
buffer: file.buffer,
|
||||
});
|
||||
return { bucket, objectPath, fileUri };
|
||||
}
|
||||
|
||||
async function createPreviewUrl(actorUid, fileUri) {
|
||||
try {
|
||||
return await generateReadSignedUrl({
|
||||
fileUri,
|
||||
actorUid,
|
||||
expiresInSeconds: 900,
|
||||
});
|
||||
} catch {
|
||||
return {
|
||||
signedUrl: null,
|
||||
expiresAt: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadProfilePhoto({ actorUid, file }) {
|
||||
const context = await requireStaffContext(actorUid);
|
||||
const uploaded = await uploadActorFile({
|
||||
actorUid,
|
||||
file,
|
||||
category: 'profile-photo',
|
||||
});
|
||||
|
||||
await withTransaction(async (client) => {
|
||||
await client.query(
|
||||
`
|
||||
UPDATE staffs
|
||||
SET metadata = COALESCE(metadata, '{}'::jsonb) || $2::jsonb,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`,
|
||||
[context.staff.staffId, JSON.stringify({ profilePhotoUri: uploaded.fileUri })]
|
||||
);
|
||||
});
|
||||
|
||||
const preview = await createPreviewUrl(actorUid, uploaded.fileUri);
|
||||
return {
|
||||
staffId: context.staff.staffId,
|
||||
fileUri: uploaded.fileUri,
|
||||
signedUrl: preview.signedUrl,
|
||||
expiresAt: preview.expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
async function requireDocument(tenantId, documentId, allowedTypes) {
|
||||
const result = await query(
|
||||
`
|
||||
SELECT id, document_type, name
|
||||
FROM documents
|
||||
WHERE tenant_id = $1
|
||||
AND id = $2
|
||||
AND document_type = ANY($3::text[])
|
||||
`,
|
||||
[tenantId, documentId, allowedTypes]
|
||||
);
|
||||
if (result.rowCount === 0) {
|
||||
throw new AppError('NOT_FOUND', 'Document not found for requested upload type', 404, {
|
||||
documentId,
|
||||
allowedTypes,
|
||||
});
|
||||
}
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
export async function uploadStaffDocument({ actorUid, documentId, file, routeType }) {
|
||||
const context = await requireStaffContext(actorUid);
|
||||
const document = await requireDocument(
|
||||
context.tenant.tenantId,
|
||||
documentId,
|
||||
routeType === 'attire' ? ['ATTIRE'] : ['DOCUMENT', 'GOVERNMENT_ID', 'TAX_FORM']
|
||||
);
|
||||
const uploaded = await uploadActorFile({
|
||||
actorUid,
|
||||
file,
|
||||
category: routeType,
|
||||
});
|
||||
const verification = await createVerificationJob({
|
||||
actorUid,
|
||||
payload: {
|
||||
type: routeType === 'attire' ? 'attire' : 'government_id',
|
||||
subjectType: routeType === 'attire' ? 'attire_item' : 'staff_document',
|
||||
subjectId: documentId,
|
||||
fileUri: uploaded.fileUri,
|
||||
metadata: {
|
||||
routeType,
|
||||
documentType: document.document_type,
|
||||
},
|
||||
rules: {
|
||||
expectedDocumentName: document.name,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await withTransaction(async (client) => {
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO staff_documents (
|
||||
tenant_id,
|
||||
staff_id,
|
||||
document_id,
|
||||
file_uri,
|
||||
status,
|
||||
verification_job_id,
|
||||
metadata
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, 'PENDING', $5, $6::jsonb)
|
||||
ON CONFLICT (staff_id, document_id) DO UPDATE
|
||||
SET file_uri = EXCLUDED.file_uri,
|
||||
status = 'PENDING',
|
||||
verification_job_id = EXCLUDED.verification_job_id,
|
||||
metadata = COALESCE(staff_documents.metadata, '{}'::jsonb) || EXCLUDED.metadata,
|
||||
updated_at = NOW()
|
||||
`,
|
||||
[
|
||||
context.tenant.tenantId,
|
||||
context.staff.staffId,
|
||||
document.id,
|
||||
uploaded.fileUri,
|
||||
verification.verificationId,
|
||||
JSON.stringify({
|
||||
verificationStatus: verification.status,
|
||||
routeType,
|
||||
}),
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
const preview = await createPreviewUrl(actorUid, uploaded.fileUri);
|
||||
return {
|
||||
documentId: document.id,
|
||||
documentType: document.document_type,
|
||||
fileUri: uploaded.fileUri,
|
||||
signedUrl: preview.signedUrl,
|
||||
expiresAt: preview.expiresAt,
|
||||
verification,
|
||||
};
|
||||
}
|
||||
|
||||
export async function uploadCertificate({ actorUid, file, payload }) {
|
||||
const context = await requireStaffContext(actorUid);
|
||||
const uploaded = await uploadActorFile({
|
||||
actorUid,
|
||||
file,
|
||||
category: 'certificate',
|
||||
});
|
||||
const verification = await createVerificationJob({
|
||||
actorUid,
|
||||
payload: {
|
||||
type: 'certification',
|
||||
subjectType: 'certificate',
|
||||
subjectId: payload.certificateType,
|
||||
fileUri: uploaded.fileUri,
|
||||
metadata: {
|
||||
certificateType: payload.certificateType,
|
||||
name: payload.name,
|
||||
issuer: payload.issuer || null,
|
||||
certificateNumber: payload.certificateNumber || null,
|
||||
},
|
||||
rules: {
|
||||
certificateType: payload.certificateType,
|
||||
name: payload.name,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const certificateResult = await withTransaction(async (client) => {
|
||||
return client.query(
|
||||
`
|
||||
INSERT INTO certificates (
|
||||
tenant_id,
|
||||
staff_id,
|
||||
certificate_type,
|
||||
certificate_number,
|
||||
issued_at,
|
||||
expires_at,
|
||||
status,
|
||||
file_uri,
|
||||
verification_job_id,
|
||||
metadata
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, NOW(), $5, 'PENDING', $6, $7, $8::jsonb)
|
||||
RETURNING id
|
||||
`,
|
||||
[
|
||||
context.tenant.tenantId,
|
||||
context.staff.staffId,
|
||||
payload.certificateType,
|
||||
payload.certificateNumber || null,
|
||||
payload.expiresAt || null,
|
||||
uploaded.fileUri,
|
||||
verification.verificationId,
|
||||
JSON.stringify({
|
||||
name: payload.name,
|
||||
issuer: payload.issuer || null,
|
||||
verificationStatus: verification.status,
|
||||
}),
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
const preview = await createPreviewUrl(actorUid, uploaded.fileUri);
|
||||
return {
|
||||
certificateId: certificateResult.rows[0].id,
|
||||
certificateType: payload.certificateType,
|
||||
fileUri: uploaded.fileUri,
|
||||
signedUrl: preview.signedUrl,
|
||||
expiresAt: preview.expiresAt,
|
||||
verification,
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteCertificate({ actorUid, certificateType }) {
|
||||
const context = await requireStaffContext(actorUid);
|
||||
const result = await query(
|
||||
`
|
||||
DELETE FROM certificates
|
||||
WHERE tenant_id = $1
|
||||
AND staff_id = $2
|
||||
AND certificate_type = $3
|
||||
RETURNING id
|
||||
`,
|
||||
[context.tenant.tenantId, context.staff.staffId, certificateType]
|
||||
);
|
||||
if (result.rowCount === 0) {
|
||||
throw new AppError('NOT_FOUND', 'Certificate not found for current staff user', 404, {
|
||||
certificateType,
|
||||
});
|
||||
}
|
||||
return {
|
||||
certificateId: result.rows[0].id,
|
||||
deleted: true,
|
||||
};
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { AppError } from '../lib/errors.js';
|
||||
import { isDatabaseConfigured, query, withTransaction } from './db.js';
|
||||
import { requireTenantContext } from './actor-context.js';
|
||||
import { invokeVertexMultimodalModel } from './llm.js';
|
||||
|
||||
const jobs = new Map();
|
||||
|
||||
export const VerificationStatus = Object.freeze({
|
||||
PENDING: 'PENDING',
|
||||
PROCESSING: 'PROCESSING',
|
||||
@@ -15,82 +14,96 @@ export const VerificationStatus = Object.freeze({
|
||||
ERROR: 'ERROR',
|
||||
});
|
||||
|
||||
const MACHINE_TERMINAL_STATUSES = new Set([
|
||||
VerificationStatus.AUTO_PASS,
|
||||
VerificationStatus.AUTO_FAIL,
|
||||
VerificationStatus.NEEDS_REVIEW,
|
||||
VerificationStatus.ERROR,
|
||||
]);
|
||||
|
||||
const HUMAN_TERMINAL_STATUSES = new Set([
|
||||
VerificationStatus.APPROVED,
|
||||
VerificationStatus.REJECTED,
|
||||
]);
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
const memoryVerificationJobs = new Map();
|
||||
|
||||
function useMemoryStore() {
|
||||
if (process.env.VERIFICATION_STORE === 'memory') {
|
||||
return true;
|
||||
}
|
||||
return !isDatabaseConfigured() && (process.env.NODE_ENV === 'test' || process.env.AUTH_BYPASS === 'true');
|
||||
}
|
||||
|
||||
function nextVerificationId() {
|
||||
if (typeof crypto?.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `verification_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
function loadMemoryJob(verificationId) {
|
||||
const job = memoryVerificationJobs.get(verificationId);
|
||||
if (!job) {
|
||||
throw new AppError('NOT_FOUND', 'Verification not found', 404, {
|
||||
verificationId,
|
||||
});
|
||||
}
|
||||
return job;
|
||||
}
|
||||
|
||||
async function processVerificationJobInMemory(verificationId) {
|
||||
const job = memoryVerificationJobs.get(verificationId);
|
||||
if (!job || job.status !== VerificationStatus.PENDING) {
|
||||
return;
|
||||
}
|
||||
|
||||
job.status = VerificationStatus.PROCESSING;
|
||||
job.updated_at = new Date().toISOString();
|
||||
memoryVerificationJobs.set(verificationId, job);
|
||||
|
||||
const workItem = {
|
||||
id: job.id,
|
||||
type: job.type,
|
||||
fileUri: job.file_uri,
|
||||
subjectType: job.subject_type,
|
||||
subjectId: job.subject_id,
|
||||
rules: job.metadata?.rules || {},
|
||||
metadata: job.metadata || {},
|
||||
};
|
||||
|
||||
try {
|
||||
const result = workItem.type === 'attire'
|
||||
? await runAttireChecks(workItem)
|
||||
: await runThirdPartyChecks(workItem, workItem.type);
|
||||
|
||||
const updated = {
|
||||
...job,
|
||||
status: result.status,
|
||||
confidence: result.confidence,
|
||||
reasons: result.reasons || [],
|
||||
extracted: result.extracted || {},
|
||||
provider_name: result.provider?.name || null,
|
||||
provider_reference: result.provider?.reference || null,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
memoryVerificationJobs.set(verificationId, updated);
|
||||
} catch (error) {
|
||||
const updated = {
|
||||
...job,
|
||||
status: VerificationStatus.ERROR,
|
||||
reasons: [error?.message || 'Verification processing failed'],
|
||||
provider_name: 'verification-worker',
|
||||
provider_reference: `error:${error?.code || 'unknown'}`,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
memoryVerificationJobs.set(verificationId, updated);
|
||||
}
|
||||
}
|
||||
|
||||
function accessMode() {
|
||||
return process.env.VERIFICATION_ACCESS_MODE || 'authenticated';
|
||||
}
|
||||
|
||||
function eventRecord({ fromStatus, toStatus, actorType, actorId, details = {} }) {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
fromStatus,
|
||||
toStatus,
|
||||
actorType,
|
||||
actorId,
|
||||
details,
|
||||
createdAt: nowIso(),
|
||||
};
|
||||
function providerTimeoutMs() {
|
||||
return Number.parseInt(process.env.VERIFICATION_PROVIDER_TIMEOUT_MS || '8000', 10);
|
||||
}
|
||||
|
||||
function toPublicJob(job) {
|
||||
return {
|
||||
verificationId: job.id,
|
||||
type: job.type,
|
||||
subjectType: job.subjectType,
|
||||
subjectId: job.subjectId,
|
||||
fileUri: job.fileUri,
|
||||
status: job.status,
|
||||
confidence: job.confidence,
|
||||
reasons: job.reasons,
|
||||
extracted: job.extracted,
|
||||
provider: job.provider,
|
||||
review: job.review,
|
||||
createdAt: job.createdAt,
|
||||
updatedAt: job.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function assertAccess(job, actorUid) {
|
||||
if (accessMode() === 'authenticated') {
|
||||
return;
|
||||
}
|
||||
if (job.ownerUid !== actorUid) {
|
||||
throw new AppError('FORBIDDEN', 'Not allowed to access this verification', 403);
|
||||
}
|
||||
}
|
||||
|
||||
function requireJob(id) {
|
||||
const job = jobs.get(id);
|
||||
if (!job) {
|
||||
throw new AppError('NOT_FOUND', 'Verification not found', 404, { verificationId: id });
|
||||
}
|
||||
return job;
|
||||
}
|
||||
|
||||
function normalizeMachineStatus(status) {
|
||||
if (
|
||||
status === VerificationStatus.AUTO_PASS
|
||||
|| status === VerificationStatus.AUTO_FAIL
|
||||
|| status === VerificationStatus.NEEDS_REVIEW
|
||||
) {
|
||||
return status;
|
||||
}
|
||||
return VerificationStatus.NEEDS_REVIEW;
|
||||
function attireModel() {
|
||||
return process.env.VERIFICATION_ATTIRE_MODEL || 'gemini-2.0-flash-lite-001';
|
||||
}
|
||||
|
||||
function clampConfidence(value, fallback = 0.5) {
|
||||
@@ -108,12 +121,89 @@ function asReasonList(reasons, fallback) {
|
||||
return [fallback];
|
||||
}
|
||||
|
||||
function providerTimeoutMs() {
|
||||
return Number.parseInt(process.env.VERIFICATION_PROVIDER_TIMEOUT_MS || '8000', 10);
|
||||
function normalizeMachineStatus(status) {
|
||||
if (
|
||||
status === VerificationStatus.AUTO_PASS
|
||||
|| status === VerificationStatus.AUTO_FAIL
|
||||
|| status === VerificationStatus.NEEDS_REVIEW
|
||||
) {
|
||||
return status;
|
||||
}
|
||||
return VerificationStatus.NEEDS_REVIEW;
|
||||
}
|
||||
|
||||
function attireModel() {
|
||||
return process.env.VERIFICATION_ATTIRE_MODEL || 'gemini-2.0-flash-lite-001';
|
||||
function toPublicJob(row) {
|
||||
if (!row) return null;
|
||||
return {
|
||||
verificationId: row.id,
|
||||
type: row.type,
|
||||
subjectType: row.subject_type,
|
||||
subjectId: row.subject_id,
|
||||
fileUri: row.file_uri,
|
||||
status: row.status,
|
||||
confidence: row.confidence == null ? null : Number(row.confidence),
|
||||
reasons: Array.isArray(row.reasons) ? row.reasons : [],
|
||||
extracted: row.extracted || {},
|
||||
provider: row.provider_name
|
||||
? {
|
||||
name: row.provider_name,
|
||||
reference: row.provider_reference || null,
|
||||
}
|
||||
: null,
|
||||
review: row.review || {},
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function assertAccess(row, actorUid) {
|
||||
if (accessMode() === 'authenticated') {
|
||||
return;
|
||||
}
|
||||
if (row.owner_user_id !== actorUid) {
|
||||
throw new AppError('FORBIDDEN', 'Not allowed to access this verification', 403);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadJob(verificationId) {
|
||||
const result = await query(
|
||||
`
|
||||
SELECT *
|
||||
FROM verification_jobs
|
||||
WHERE id = $1
|
||||
`,
|
||||
[verificationId]
|
||||
);
|
||||
if (result.rowCount === 0) {
|
||||
throw new AppError('NOT_FOUND', 'Verification not found', 404, {
|
||||
verificationId,
|
||||
});
|
||||
}
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
async function appendVerificationEvent(client, {
|
||||
verificationJobId,
|
||||
fromStatus,
|
||||
toStatus,
|
||||
actorType,
|
||||
actorId,
|
||||
details = {},
|
||||
}) {
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO verification_events (
|
||||
verification_job_id,
|
||||
from_status,
|
||||
to_status,
|
||||
actor_type,
|
||||
actor_id,
|
||||
details
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6::jsonb)
|
||||
`,
|
||||
[verificationJobId, fromStatus, toStatus, actorType, actorId, JSON.stringify(details)]
|
||||
);
|
||||
}
|
||||
|
||||
async function runAttireChecks(job) {
|
||||
@@ -258,47 +348,26 @@ async function runThirdPartyChecks(job, type) {
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const bodyText = await response.text();
|
||||
let body = {};
|
||||
try {
|
||||
body = bodyText ? JSON.parse(bodyText) : {};
|
||||
} catch {
|
||||
body = {};
|
||||
}
|
||||
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
return {
|
||||
status: VerificationStatus.NEEDS_REVIEW,
|
||||
confidence: 0.35,
|
||||
reasons: [`${provider.name} returned ${response.status}`],
|
||||
extracted: {},
|
||||
provider: {
|
||||
name: provider.name,
|
||||
reference: body?.reference || null,
|
||||
},
|
||||
};
|
||||
throw new Error(payload?.error || payload?.message || `${provider.name} failed`);
|
||||
}
|
||||
|
||||
return {
|
||||
status: normalizeMachineStatus(body.status),
|
||||
confidence: clampConfidence(body.confidence, 0.6),
|
||||
reasons: asReasonList(body.reasons, `${provider.name} completed check`),
|
||||
extracted: body.extracted || {},
|
||||
status: normalizeMachineStatus(payload.status),
|
||||
confidence: clampConfidence(payload.confidence, 0.6),
|
||||
reasons: asReasonList(payload.reasons, `${provider.name} completed`),
|
||||
extracted: payload.extracted || {},
|
||||
provider: {
|
||||
name: provider.name,
|
||||
reference: body.reference || null,
|
||||
reference: payload.reference || null,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
const isAbort = error?.name === 'AbortError';
|
||||
return {
|
||||
status: VerificationStatus.NEEDS_REVIEW,
|
||||
confidence: 0.3,
|
||||
reasons: [
|
||||
isAbort
|
||||
? `${provider.name} timeout, manual review required`
|
||||
: `${provider.name} unavailable, manual review required`,
|
||||
],
|
||||
confidence: 0.35,
|
||||
reasons: [error?.message || `${provider.name} unavailable`],
|
||||
extracted: {},
|
||||
provider: {
|
||||
name: provider.name,
|
||||
@@ -310,201 +379,462 @@ async function runThirdPartyChecks(job, type) {
|
||||
}
|
||||
}
|
||||
|
||||
async function runMachineChecks(job) {
|
||||
if (job.type === 'attire') {
|
||||
return runAttireChecks(job);
|
||||
}
|
||||
async function processVerificationJob(verificationId) {
|
||||
const startedJob = await withTransaction(async (client) => {
|
||||
const result = await client.query(
|
||||
`
|
||||
SELECT *
|
||||
FROM verification_jobs
|
||||
WHERE id = $1
|
||||
FOR UPDATE
|
||||
`,
|
||||
[verificationId]
|
||||
);
|
||||
|
||||
if (job.type === 'government_id') {
|
||||
return runThirdPartyChecks(job, 'government_id');
|
||||
}
|
||||
if (result.rowCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return runThirdPartyChecks(job, 'certification');
|
||||
}
|
||||
const job = result.rows[0];
|
||||
if (job.status !== VerificationStatus.PENDING) {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function processVerificationJob(id) {
|
||||
const job = requireJob(id);
|
||||
if (job.status !== VerificationStatus.PENDING) {
|
||||
await client.query(
|
||||
`
|
||||
UPDATE verification_jobs
|
||||
SET status = $2,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`,
|
||||
[verificationId, VerificationStatus.PROCESSING]
|
||||
);
|
||||
|
||||
await appendVerificationEvent(client, {
|
||||
verificationJobId: verificationId,
|
||||
fromStatus: job.status,
|
||||
toStatus: VerificationStatus.PROCESSING,
|
||||
actorType: 'worker',
|
||||
actorId: 'verification-worker',
|
||||
});
|
||||
|
||||
return {
|
||||
id: verificationId,
|
||||
type: job.type,
|
||||
fileUri: job.file_uri,
|
||||
subjectType: job.subject_type,
|
||||
subjectId: job.subject_id,
|
||||
rules: job.metadata?.rules || {},
|
||||
metadata: job.metadata || {},
|
||||
};
|
||||
});
|
||||
|
||||
if (!startedJob) {
|
||||
return;
|
||||
}
|
||||
|
||||
const beforeProcessing = job.status;
|
||||
job.status = VerificationStatus.PROCESSING;
|
||||
job.updatedAt = nowIso();
|
||||
job.events.push(
|
||||
eventRecord({
|
||||
fromStatus: beforeProcessing,
|
||||
toStatus: VerificationStatus.PROCESSING,
|
||||
actorType: 'system',
|
||||
actorId: 'verification-worker',
|
||||
})
|
||||
);
|
||||
|
||||
try {
|
||||
const outcome = await runMachineChecks(job);
|
||||
if (!MACHINE_TERMINAL_STATUSES.has(outcome.status)) {
|
||||
throw new Error(`Invalid machine outcome status: ${outcome.status}`);
|
||||
}
|
||||
const fromStatus = job.status;
|
||||
job.status = outcome.status;
|
||||
job.confidence = outcome.confidence;
|
||||
job.reasons = outcome.reasons;
|
||||
job.extracted = outcome.extracted;
|
||||
job.provider = outcome.provider;
|
||||
job.updatedAt = nowIso();
|
||||
job.events.push(
|
||||
eventRecord({
|
||||
fromStatus,
|
||||
toStatus: job.status,
|
||||
actorType: 'system',
|
||||
const result = startedJob.type === 'attire'
|
||||
? await runAttireChecks(startedJob)
|
||||
: await runThirdPartyChecks(startedJob, startedJob.type);
|
||||
|
||||
await withTransaction(async (client) => {
|
||||
await client.query(
|
||||
`
|
||||
UPDATE verification_jobs
|
||||
SET status = $2,
|
||||
confidence = $3,
|
||||
reasons = $4::jsonb,
|
||||
extracted = $5::jsonb,
|
||||
provider_name = $6,
|
||||
provider_reference = $7,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`,
|
||||
[
|
||||
verificationId,
|
||||
result.status,
|
||||
result.confidence,
|
||||
JSON.stringify(result.reasons || []),
|
||||
JSON.stringify(result.extracted || {}),
|
||||
result.provider?.name || null,
|
||||
result.provider?.reference || null,
|
||||
]
|
||||
);
|
||||
|
||||
await appendVerificationEvent(client, {
|
||||
verificationJobId: verificationId,
|
||||
fromStatus: VerificationStatus.PROCESSING,
|
||||
toStatus: result.status,
|
||||
actorType: 'worker',
|
||||
actorId: 'verification-worker',
|
||||
details: {
|
||||
confidence: job.confidence,
|
||||
reasons: job.reasons,
|
||||
provider: job.provider,
|
||||
confidence: result.confidence,
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
const fromStatus = job.status;
|
||||
job.status = VerificationStatus.ERROR;
|
||||
job.confidence = null;
|
||||
job.reasons = [error?.message || 'Verification processing failed'];
|
||||
job.extracted = {};
|
||||
job.provider = {
|
||||
name: 'verification-worker',
|
||||
reference: null,
|
||||
};
|
||||
job.updatedAt = nowIso();
|
||||
job.events.push(
|
||||
eventRecord({
|
||||
fromStatus,
|
||||
await withTransaction(async (client) => {
|
||||
await client.query(
|
||||
`
|
||||
UPDATE verification_jobs
|
||||
SET status = $2,
|
||||
reasons = $3::jsonb,
|
||||
provider_name = 'verification-worker',
|
||||
provider_reference = $4,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`,
|
||||
[
|
||||
verificationId,
|
||||
VerificationStatus.ERROR,
|
||||
JSON.stringify([error?.message || 'Verification processing failed']),
|
||||
`error:${error?.code || 'unknown'}`,
|
||||
]
|
||||
);
|
||||
|
||||
await appendVerificationEvent(client, {
|
||||
verificationJobId: verificationId,
|
||||
fromStatus: VerificationStatus.PROCESSING,
|
||||
toStatus: VerificationStatus.ERROR,
|
||||
actorType: 'system',
|
||||
actorType: 'worker',
|
||||
actorId: 'verification-worker',
|
||||
details: {
|
||||
error: error?.message || 'Verification processing failed',
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function queueVerificationProcessing(id) {
|
||||
setTimeout(() => {
|
||||
processVerificationJob(id).catch(() => {});
|
||||
}, 0);
|
||||
}
|
||||
|
||||
export function createVerificationJob({ actorUid, payload }) {
|
||||
const now = nowIso();
|
||||
const id = `ver_${crypto.randomUUID()}`;
|
||||
const job = {
|
||||
id,
|
||||
type: payload.type,
|
||||
subjectType: payload.subjectType || null,
|
||||
subjectId: payload.subjectId || null,
|
||||
ownerUid: actorUid,
|
||||
fileUri: payload.fileUri,
|
||||
rules: payload.rules || {},
|
||||
metadata: payload.metadata || {},
|
||||
status: VerificationStatus.PENDING,
|
||||
confidence: null,
|
||||
reasons: [],
|
||||
extracted: {},
|
||||
provider: null,
|
||||
review: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
events: [
|
||||
eventRecord({
|
||||
fromStatus: null,
|
||||
toStatus: VerificationStatus.PENDING,
|
||||
actorType: 'system',
|
||||
actorId: actorUid,
|
||||
}),
|
||||
],
|
||||
};
|
||||
jobs.set(id, job);
|
||||
queueVerificationProcessing(id);
|
||||
return toPublicJob(job);
|
||||
}
|
||||
|
||||
export function getVerificationJob(verificationId, actorUid) {
|
||||
const job = requireJob(verificationId);
|
||||
assertAccess(job, actorUid);
|
||||
return toPublicJob(job);
|
||||
}
|
||||
|
||||
export function reviewVerificationJob(verificationId, actorUid, review) {
|
||||
const job = requireJob(verificationId);
|
||||
assertAccess(job, actorUid);
|
||||
|
||||
if (HUMAN_TERMINAL_STATUSES.has(job.status)) {
|
||||
throw new AppError('CONFLICT', 'Verification already finalized', 409, {
|
||||
verificationId,
|
||||
status: job.status,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const fromStatus = job.status;
|
||||
job.status = review.decision;
|
||||
job.review = {
|
||||
decision: review.decision,
|
||||
reviewedBy: actorUid,
|
||||
reviewedAt: nowIso(),
|
||||
note: review.note || '',
|
||||
reasonCode: review.reasonCode || 'MANUAL_REVIEW',
|
||||
};
|
||||
job.updatedAt = nowIso();
|
||||
job.events.push(
|
||||
eventRecord({
|
||||
fromStatus,
|
||||
toStatus: job.status,
|
||||
function queueVerificationProcessing(verificationId) {
|
||||
setImmediate(() => {
|
||||
const worker = useMemoryStore() ? processVerificationJobInMemory : processVerificationJob;
|
||||
worker(verificationId).catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
export async function createVerificationJob({ actorUid, payload }) {
|
||||
if (useMemoryStore()) {
|
||||
const timestamp = new Date().toISOString();
|
||||
const created = {
|
||||
id: nextVerificationId(),
|
||||
tenant_id: null,
|
||||
staff_id: null,
|
||||
owner_user_id: actorUid,
|
||||
type: payload.type,
|
||||
subject_type: payload.subjectType || null,
|
||||
subject_id: payload.subjectId || null,
|
||||
file_uri: payload.fileUri,
|
||||
status: VerificationStatus.PENDING,
|
||||
confidence: null,
|
||||
reasons: [],
|
||||
extracted: {},
|
||||
provider_name: null,
|
||||
provider_reference: null,
|
||||
review: {},
|
||||
metadata: {
|
||||
...(payload.metadata || {}),
|
||||
rules: payload.rules || {},
|
||||
},
|
||||
created_at: timestamp,
|
||||
updated_at: timestamp,
|
||||
};
|
||||
memoryVerificationJobs.set(created.id, created);
|
||||
queueVerificationProcessing(created.id);
|
||||
return toPublicJob(created);
|
||||
}
|
||||
|
||||
const context = await requireTenantContext(actorUid);
|
||||
const created = await withTransaction(async (client) => {
|
||||
const result = await client.query(
|
||||
`
|
||||
INSERT INTO verification_jobs (
|
||||
tenant_id,
|
||||
staff_id,
|
||||
document_id,
|
||||
owner_user_id,
|
||||
type,
|
||||
subject_type,
|
||||
subject_id,
|
||||
file_uri,
|
||||
status,
|
||||
reasons,
|
||||
extracted,
|
||||
review,
|
||||
metadata
|
||||
)
|
||||
VALUES (
|
||||
$1,
|
||||
$2,
|
||||
NULL,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
$6,
|
||||
$7,
|
||||
'PENDING',
|
||||
'[]'::jsonb,
|
||||
'{}'::jsonb,
|
||||
'{}'::jsonb,
|
||||
$8::jsonb
|
||||
)
|
||||
RETURNING *
|
||||
`,
|
||||
[
|
||||
context.tenant.tenantId,
|
||||
context.staff?.staffId || null,
|
||||
actorUid,
|
||||
payload.type,
|
||||
payload.subjectType || null,
|
||||
payload.subjectId || null,
|
||||
payload.fileUri,
|
||||
JSON.stringify({
|
||||
...(payload.metadata || {}),
|
||||
rules: payload.rules || {},
|
||||
}),
|
||||
]
|
||||
);
|
||||
|
||||
await appendVerificationEvent(client, {
|
||||
verificationJobId: result.rows[0].id,
|
||||
fromStatus: null,
|
||||
toStatus: VerificationStatus.PENDING,
|
||||
actorType: 'system',
|
||||
actorId: actorUid,
|
||||
});
|
||||
|
||||
return result.rows[0];
|
||||
});
|
||||
|
||||
queueVerificationProcessing(created.id);
|
||||
return toPublicJob(created);
|
||||
}
|
||||
|
||||
export async function getVerificationJob(verificationId, actorUid) {
|
||||
if (useMemoryStore()) {
|
||||
const job = loadMemoryJob(verificationId);
|
||||
assertAccess(job, actorUid);
|
||||
return toPublicJob(job);
|
||||
}
|
||||
|
||||
const job = await loadJob(verificationId);
|
||||
assertAccess(job, actorUid);
|
||||
return toPublicJob(job);
|
||||
}
|
||||
|
||||
export async function reviewVerificationJob(verificationId, actorUid, review) {
|
||||
if (useMemoryStore()) {
|
||||
const job = loadMemoryJob(verificationId);
|
||||
assertAccess(job, actorUid);
|
||||
if (HUMAN_TERMINAL_STATUSES.has(job.status)) {
|
||||
throw new AppError('CONFLICT', 'Verification already finalized', 409, {
|
||||
verificationId,
|
||||
status: job.status,
|
||||
});
|
||||
}
|
||||
|
||||
const reviewPayload = {
|
||||
decision: review.decision,
|
||||
reviewedBy: actorUid,
|
||||
reviewedAt: new Date().toISOString(),
|
||||
note: review.note || '',
|
||||
reasonCode: review.reasonCode || 'MANUAL_REVIEW',
|
||||
};
|
||||
|
||||
const updated = {
|
||||
...job,
|
||||
status: review.decision,
|
||||
review: reviewPayload,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
memoryVerificationJobs.set(verificationId, updated);
|
||||
return toPublicJob(updated);
|
||||
}
|
||||
|
||||
const context = await requireTenantContext(actorUid);
|
||||
const updated = await withTransaction(async (client) => {
|
||||
const result = await client.query(
|
||||
`
|
||||
SELECT *
|
||||
FROM verification_jobs
|
||||
WHERE id = $1
|
||||
FOR UPDATE
|
||||
`,
|
||||
[verificationId]
|
||||
);
|
||||
if (result.rowCount === 0) {
|
||||
throw new AppError('NOT_FOUND', 'Verification not found', 404, { verificationId });
|
||||
}
|
||||
|
||||
const job = result.rows[0];
|
||||
assertAccess(job, actorUid);
|
||||
if (HUMAN_TERMINAL_STATUSES.has(job.status)) {
|
||||
throw new AppError('CONFLICT', 'Verification already finalized', 409, {
|
||||
verificationId,
|
||||
status: job.status,
|
||||
});
|
||||
}
|
||||
|
||||
const reviewPayload = {
|
||||
decision: review.decision,
|
||||
reviewedBy: actorUid,
|
||||
reviewedAt: new Date().toISOString(),
|
||||
note: review.note || '',
|
||||
reasonCode: review.reasonCode || 'MANUAL_REVIEW',
|
||||
};
|
||||
|
||||
await client.query(
|
||||
`
|
||||
UPDATE verification_jobs
|
||||
SET status = $2,
|
||||
review = $3::jsonb,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`,
|
||||
[verificationId, review.decision, JSON.stringify(reviewPayload)]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO verification_reviews (
|
||||
verification_job_id,
|
||||
reviewer_user_id,
|
||||
decision,
|
||||
note,
|
||||
reason_code
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
`,
|
||||
[verificationId, actorUid, review.decision, review.note || null, review.reasonCode || 'MANUAL_REVIEW']
|
||||
);
|
||||
|
||||
await appendVerificationEvent(client, {
|
||||
verificationJobId: verificationId,
|
||||
fromStatus: job.status,
|
||||
toStatus: review.decision,
|
||||
actorType: 'reviewer',
|
||||
actorId: actorUid,
|
||||
details: {
|
||||
reasonCode: job.review.reasonCode,
|
||||
reasonCode: review.reasonCode || 'MANUAL_REVIEW',
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
return toPublicJob(job);
|
||||
return {
|
||||
...job,
|
||||
status: review.decision,
|
||||
review: reviewPayload,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
});
|
||||
|
||||
void context;
|
||||
return toPublicJob(updated);
|
||||
}
|
||||
|
||||
export function retryVerificationJob(verificationId, actorUid) {
|
||||
const job = requireJob(verificationId);
|
||||
assertAccess(job, actorUid);
|
||||
export async function retryVerificationJob(verificationId, actorUid) {
|
||||
if (useMemoryStore()) {
|
||||
const job = loadMemoryJob(verificationId);
|
||||
assertAccess(job, actorUid);
|
||||
if (job.status === VerificationStatus.PROCESSING) {
|
||||
throw new AppError('CONFLICT', 'Cannot retry while verification is processing', 409, {
|
||||
verificationId,
|
||||
});
|
||||
}
|
||||
|
||||
if (job.status === VerificationStatus.PROCESSING) {
|
||||
throw new AppError('CONFLICT', 'Cannot retry while verification is processing', 409, {
|
||||
verificationId,
|
||||
});
|
||||
const updated = {
|
||||
...job,
|
||||
status: VerificationStatus.PENDING,
|
||||
confidence: null,
|
||||
reasons: [],
|
||||
extracted: {},
|
||||
provider_name: null,
|
||||
provider_reference: null,
|
||||
review: {},
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
memoryVerificationJobs.set(verificationId, updated);
|
||||
queueVerificationProcessing(verificationId);
|
||||
return toPublicJob(updated);
|
||||
}
|
||||
|
||||
const fromStatus = job.status;
|
||||
job.status = VerificationStatus.PENDING;
|
||||
job.confidence = null;
|
||||
job.reasons = [];
|
||||
job.extracted = {};
|
||||
job.provider = null;
|
||||
job.review = null;
|
||||
job.updatedAt = nowIso();
|
||||
job.events.push(
|
||||
eventRecord({
|
||||
fromStatus,
|
||||
const updated = await withTransaction(async (client) => {
|
||||
const result = await client.query(
|
||||
`
|
||||
SELECT *
|
||||
FROM verification_jobs
|
||||
WHERE id = $1
|
||||
FOR UPDATE
|
||||
`,
|
||||
[verificationId]
|
||||
);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
throw new AppError('NOT_FOUND', 'Verification not found', 404, { verificationId });
|
||||
}
|
||||
|
||||
const job = result.rows[0];
|
||||
assertAccess(job, actorUid);
|
||||
if (job.status === VerificationStatus.PROCESSING) {
|
||||
throw new AppError('CONFLICT', 'Cannot retry while verification is processing', 409, {
|
||||
verificationId,
|
||||
});
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`
|
||||
UPDATE verification_jobs
|
||||
SET status = $2,
|
||||
confidence = NULL,
|
||||
reasons = '[]'::jsonb,
|
||||
extracted = '{}'::jsonb,
|
||||
provider_name = NULL,
|
||||
provider_reference = NULL,
|
||||
review = '{}'::jsonb,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`,
|
||||
[verificationId, VerificationStatus.PENDING]
|
||||
);
|
||||
|
||||
await appendVerificationEvent(client, {
|
||||
verificationJobId: verificationId,
|
||||
fromStatus: job.status,
|
||||
toStatus: VerificationStatus.PENDING,
|
||||
actorType: 'reviewer',
|
||||
actorId: actorUid,
|
||||
details: {
|
||||
retried: true,
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
...job,
|
||||
status: VerificationStatus.PENDING,
|
||||
confidence: null,
|
||||
reasons: [],
|
||||
extracted: {},
|
||||
provider_name: null,
|
||||
provider_reference: null,
|
||||
review: {},
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
});
|
||||
|
||||
queueVerificationProcessing(verificationId);
|
||||
return toPublicJob(job);
|
||||
return toPublicJob(updated);
|
||||
}
|
||||
|
||||
export function __resetVerificationJobsForTests() {
|
||||
jobs.clear();
|
||||
export async function __resetVerificationJobsForTests() {
|
||||
if (process.env.NODE_ENV !== 'test' && process.env.AUTH_BYPASS !== 'true') {
|
||||
return;
|
||||
}
|
||||
memoryVerificationJobs.clear();
|
||||
try {
|
||||
await query('DELETE FROM verification_reviews');
|
||||
await query('DELETE FROM verification_events');
|
||||
await query('DELETE FROM verification_jobs');
|
||||
} catch {
|
||||
// Intentionally ignore when tests run without a configured database.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { createApp } from '../src/app.js';
|
||||
import { __resetLlmRateLimitForTests } from '../src/services/llm-rate-limit.js';
|
||||
import { __resetVerificationJobsForTests } from '../src/services/verification-jobs.js';
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
process.env.AUTH_BYPASS = 'true';
|
||||
process.env.LLM_MOCK = 'true';
|
||||
process.env.SIGNED_URL_MOCK = 'true';
|
||||
@@ -15,8 +15,9 @@ beforeEach(() => {
|
||||
process.env.VERIFICATION_REQUIRE_FILE_EXISTS = 'false';
|
||||
process.env.VERIFICATION_ACCESS_MODE = 'authenticated';
|
||||
process.env.VERIFICATION_ATTIRE_PROVIDER = 'mock';
|
||||
process.env.VERIFICATION_STORE = 'memory';
|
||||
__resetLlmRateLimitForTests();
|
||||
__resetVerificationJobsForTests();
|
||||
await __resetVerificationJobsForTests();
|
||||
});
|
||||
|
||||
async function waitForMachineStatus(app, verificationId, maxAttempts = 30) {
|
||||
@@ -49,6 +50,22 @@ test('GET /healthz returns healthy response', async () => {
|
||||
assert.equal(typeof res.headers['x-request-id'], 'string');
|
||||
});
|
||||
|
||||
test('GET /readyz reports database not configured when env is absent', async () => {
|
||||
delete process.env.DATABASE_URL;
|
||||
delete process.env.DB_HOST;
|
||||
delete process.env.DB_NAME;
|
||||
delete process.env.DB_USER;
|
||||
delete process.env.DB_PASSWORD;
|
||||
delete process.env.INSTANCE_CONNECTION_NAME;
|
||||
delete process.env.VERIFICATION_STORE;
|
||||
|
||||
const app = createApp();
|
||||
const res = await request(app).get('/readyz');
|
||||
|
||||
assert.equal(res.status, 503);
|
||||
assert.equal(res.body.status, 'DATABASE_NOT_CONFIGURED');
|
||||
});
|
||||
|
||||
test('POST /core/create-signed-url requires auth', async () => {
|
||||
process.env.AUTH_BYPASS = 'false';
|
||||
const app = createApp();
|
||||
|
||||
@@ -16,15 +16,18 @@ mutation unseedAll @auth(level: USER) {
|
||||
invoiceTemplate_deleteMany(all: true)
|
||||
customRateCard_deleteMany(all: true)
|
||||
vendorRate_deleteMany(all: true)
|
||||
vendorBenefitPlan_deleteMany(all: true)
|
||||
benefitsData_deleteMany(all: true)
|
||||
workforce_deleteMany(all: true)
|
||||
staffCourse_deleteMany(all: true)
|
||||
staffDocument_deleteMany(all: true)
|
||||
staffAttire_deleteMany(all: true)
|
||||
staffRole_deleteMany(all: true)
|
||||
staffAvailability_deleteMany(all: true)
|
||||
staffAvailabilityStats_deleteMany(all: true)
|
||||
emergencyContact_deleteMany(all: true)
|
||||
taxForm_deleteMany(all: true)
|
||||
certificate_deleteMany(all: true)
|
||||
vendorBenefitPlan_deleteMany(all: true)
|
||||
|
||||
# ----------------------------------
|
||||
# Tasks / Shifts / Orders
|
||||
@@ -33,6 +36,7 @@ mutation unseedAll @auth(level: USER) {
|
||||
shiftRole_deleteMany(all: true)
|
||||
shift_deleteMany(all: true)
|
||||
order_deleteMany(all: true)
|
||||
costCenter_deleteMany(all: true)
|
||||
|
||||
# ----------------------------------
|
||||
# Teams / Hubs / Org
|
||||
@@ -52,7 +56,6 @@ mutation unseedAll @auth(level: USER) {
|
||||
level_deleteMany(all: true)
|
||||
course_deleteMany(all: true)
|
||||
faqData_deleteMany(all: true)
|
||||
benefitsData_deleteMany(all: true)
|
||||
attireOption_deleteMany(all: true)
|
||||
document_deleteMany(all: true)
|
||||
|
||||
|
||||
@@ -493,6 +493,208 @@ mutation seedAll @transaction {
|
||||
}
|
||||
)
|
||||
|
||||
# Workforce assignments
|
||||
workforce_1: workforce_insert(
|
||||
data: {
|
||||
id: "7f1d6cf2-4a26-4a3e-9d1b-1d68ca2a1001"
|
||||
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
|
||||
staffId: "633df3ce-b92c-473f-90d8-38dd027fdf57"
|
||||
workforceNumber: "WF-1001"
|
||||
employmentType: W2
|
||||
status: ACTIVE
|
||||
createdBy: "seed-script"
|
||||
}
|
||||
)
|
||||
workforce_2: workforce_insert(
|
||||
data: {
|
||||
id: "7f1d6cf2-4a26-4a3e-9d1b-1d68ca2a1002"
|
||||
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
|
||||
staffId: "9631581a-1601-4e06-8e5e-600e9f305bcf"
|
||||
workforceNumber: "WF-1002"
|
||||
employmentType: W1099
|
||||
status: ACTIVE
|
||||
createdBy: "seed-script"
|
||||
}
|
||||
)
|
||||
workforce_3: workforce_insert(
|
||||
data: {
|
||||
id: "7f1d6cf2-4a26-4a3e-9d1b-1d68ca2a1003"
|
||||
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
|
||||
staffId: "2b678a6d-b8cd-4d5e-95ae-f35e4569f92c"
|
||||
workforceNumber: "WF-1003"
|
||||
employmentType: W2
|
||||
status: ACTIVE
|
||||
createdBy: "seed-script"
|
||||
}
|
||||
)
|
||||
workforce_4: workforce_insert(
|
||||
data: {
|
||||
id: "7f1d6cf2-4a26-4a3e-9d1b-1d68ca2a1004"
|
||||
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
|
||||
staffId: "d62605f9-366d-42c5-8f3b-f276c0d27ea3"
|
||||
workforceNumber: "WF-1004"
|
||||
employmentType: W2
|
||||
status: ACTIVE
|
||||
createdBy: "seed-script"
|
||||
}
|
||||
)
|
||||
workforce_5: workforce_insert(
|
||||
data: {
|
||||
id: "7f1d6cf2-4a26-4a3e-9d1b-1d68ca2a1005"
|
||||
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
|
||||
staffId: "c6428f90-9c29-4e5c-b362-dc67a9a8cbba"
|
||||
workforceNumber: "WF-1005"
|
||||
employmentType: W1099
|
||||
status: ACTIVE
|
||||
createdBy: "seed-script"
|
||||
}
|
||||
)
|
||||
workforce_6: workforce_insert(
|
||||
data: {
|
||||
id: "7f1d6cf2-4a26-4a3e-9d1b-1d68ca2a1006"
|
||||
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
|
||||
staffId: "56d7178c-f4ab-4c50-9b1f-d6efe25ba50b"
|
||||
workforceNumber: "WF-1006"
|
||||
employmentType: CONTRACT
|
||||
status: ACTIVE
|
||||
createdBy: "seed-script"
|
||||
}
|
||||
)
|
||||
workforce_7: workforce_insert(
|
||||
data: {
|
||||
id: "7f1d6cf2-4a26-4a3e-9d1b-1d68ca2a1007"
|
||||
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
|
||||
staffId: "e7f8a9b0-c1d2-4e5f-a6b7-c8d9e0f1a2b3"
|
||||
workforceNumber: "WF-1007"
|
||||
employmentType: W2
|
||||
status: ACTIVE
|
||||
createdBy: "seed-script"
|
||||
}
|
||||
)
|
||||
|
||||
# Benefit plans
|
||||
benefit_plan_1: vendorBenefitPlan_insert(
|
||||
data: {
|
||||
id: "2d8f7d4b-1f90-4d8b-8b9d-9200d8f01001"
|
||||
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
|
||||
title: "Paid Time Off"
|
||||
description: "Annual paid time off allowance."
|
||||
requestLabel: "Request PTO"
|
||||
total: 80
|
||||
isActive: true
|
||||
}
|
||||
)
|
||||
benefit_plan_2: vendorBenefitPlan_insert(
|
||||
data: {
|
||||
id: "2d8f7d4b-1f90-4d8b-8b9d-9200d8f01002"
|
||||
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
|
||||
title: "Sick Leave"
|
||||
description: "Paid sick leave balance."
|
||||
requestLabel: "Request Sick Leave"
|
||||
total: 40
|
||||
isActive: true
|
||||
}
|
||||
)
|
||||
benefit_plan_3: vendorBenefitPlan_insert(
|
||||
data: {
|
||||
id: "2d8f7d4b-1f90-4d8b-8b9d-9200d8f01003"
|
||||
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
|
||||
title: "Training Hours"
|
||||
description: "Vendor-sponsored training time."
|
||||
requestLabel: "Request Training Hours"
|
||||
total: 24
|
||||
isActive: true
|
||||
}
|
||||
)
|
||||
|
||||
# Benefits balances (remaining hours)
|
||||
benefits_data_1: benefitsData_insert(
|
||||
data: {
|
||||
id: "aa8bf762-141e-4c69-ae15-7c5416fd1101"
|
||||
vendorBenefitPlanId: "2d8f7d4b-1f90-4d8b-8b9d-9200d8f01001"
|
||||
staffId: "633df3ce-b92c-473f-90d8-38dd027fdf57"
|
||||
current: 52
|
||||
}
|
||||
)
|
||||
benefits_data_2: benefitsData_insert(
|
||||
data: {
|
||||
id: "aa8bf762-141e-4c69-ae15-7c5416fd1102"
|
||||
vendorBenefitPlanId: "2d8f7d4b-1f90-4d8b-8b9d-9200d8f01002"
|
||||
staffId: "633df3ce-b92c-473f-90d8-38dd027fdf57"
|
||||
current: 30
|
||||
}
|
||||
)
|
||||
benefits_data_3: benefitsData_insert(
|
||||
data: {
|
||||
id: "aa8bf762-141e-4c69-ae15-7c5416fd1103"
|
||||
vendorBenefitPlanId: "2d8f7d4b-1f90-4d8b-8b9d-9200d8f01003"
|
||||
staffId: "633df3ce-b92c-473f-90d8-38dd027fdf57"
|
||||
current: 16
|
||||
}
|
||||
)
|
||||
benefits_data_4: benefitsData_insert(
|
||||
data: {
|
||||
id: "aa8bf762-141e-4c69-ae15-7c5416fd1104"
|
||||
vendorBenefitPlanId: "2d8f7d4b-1f90-4d8b-8b9d-9200d8f01001"
|
||||
staffId: "e7f8a9b0-c1d2-4e5f-a6b7-c8d9e0f1a2b3"
|
||||
current: 64
|
||||
}
|
||||
)
|
||||
benefits_data_5: benefitsData_insert(
|
||||
data: {
|
||||
id: "aa8bf762-141e-4c69-ae15-7c5416fd1105"
|
||||
vendorBenefitPlanId: "2d8f7d4b-1f90-4d8b-8b9d-9200d8f01002"
|
||||
staffId: "e7f8a9b0-c1d2-4e5f-a6b7-c8d9e0f1a2b3"
|
||||
current: 36
|
||||
}
|
||||
)
|
||||
benefits_data_6: benefitsData_insert(
|
||||
data: {
|
||||
id: "aa8bf762-141e-4c69-ae15-7c5416fd1106"
|
||||
vendorBenefitPlanId: "2d8f7d4b-1f90-4d8b-8b9d-9200d8f01003"
|
||||
staffId: "e7f8a9b0-c1d2-4e5f-a6b7-c8d9e0f1a2b3"
|
||||
current: 20
|
||||
}
|
||||
)
|
||||
|
||||
# Bank accounts (client + staff)
|
||||
account_1: account_insert(
|
||||
data: {
|
||||
id: "ed6fd954-3f25-4ab7-b44f-2f03f1ca5101"
|
||||
bank: "Bank of America"
|
||||
type: CHECKING
|
||||
last4: "4455"
|
||||
isPrimary: true
|
||||
accountNumber: "883104455"
|
||||
routeNumber: "121000358"
|
||||
ownerId: "ef69e942-d6e5-48e5-a8bc-69d3faa63b2f"
|
||||
}
|
||||
)
|
||||
account_2: account_insert(
|
||||
data: {
|
||||
id: "ed6fd954-3f25-4ab7-b44f-2f03f1ca5102"
|
||||
bank: "Chase"
|
||||
type: CHECKING
|
||||
last4: "3301"
|
||||
isPrimary: true
|
||||
accountNumber: "009813301"
|
||||
routeNumber: "322271627"
|
||||
ownerId: "633df3ce-b92c-473f-90d8-38dd027fdf57"
|
||||
}
|
||||
)
|
||||
account_3: account_insert(
|
||||
data: {
|
||||
id: "ed6fd954-3f25-4ab7-b44f-2f03f1ca5103"
|
||||
bank: "Wells Fargo"
|
||||
type: SAVINGS
|
||||
last4: "7812"
|
||||
isPrimary: true
|
||||
accountNumber: "114927812"
|
||||
routeNumber: "121042882"
|
||||
ownerId: "e7f8a9b0-c1d2-4e5f-a6b7-c8d9e0f1a2b3"
|
||||
}
|
||||
)
|
||||
|
||||
# Documents
|
||||
document_1: document_insert(
|
||||
data: {
|
||||
@@ -575,6 +777,41 @@ mutation seedAll @transaction {
|
||||
verificationId: "verif_staff3_id_001"
|
||||
}
|
||||
)
|
||||
staff_document_5: staffDocument_insert(
|
||||
data: {
|
||||
id: "bf01f474-2f2d-40f5-8ca7-0b6a1e52dd8a"
|
||||
staffId: "e7f8a9b0-c1d2-4e5f-a6b7-c8d9e0f1a2b3"
|
||||
staffName: "Test Staff"
|
||||
documentId: "9fd8b1bb-63b4-4480-a53e-c65ae5f03ea8"
|
||||
status: VERIFIED
|
||||
documentUrl: "https://storage.googleapis.com/krow-workforce-dev/docs/staff-7/w4.pdf"
|
||||
verificationId: "verif_staff7_w4_001"
|
||||
verifiedAt: "2026-02-20T17:15:00Z"
|
||||
}
|
||||
)
|
||||
staff_document_6: staffDocument_insert(
|
||||
data: {
|
||||
id: "bf01f474-2f2d-40f5-8ca7-0b6a1e52dd8b"
|
||||
staffId: "e7f8a9b0-c1d2-4e5f-a6b7-c8d9e0f1a2b3"
|
||||
staffName: "Test Staff"
|
||||
documentId: "f3389b80-9407-45ca-bdbe-6aeb873f2f5d"
|
||||
status: VERIFIED
|
||||
documentUrl: "https://storage.googleapis.com/krow-workforce-dev/docs/staff-7/direct-deposit.pdf"
|
||||
verificationId: "verif_staff7_dd_001"
|
||||
verifiedAt: "2026-02-20T18:00:00Z"
|
||||
}
|
||||
)
|
||||
staff_document_7: staffDocument_insert(
|
||||
data: {
|
||||
id: "bf01f474-2f2d-40f5-8ca7-0b6a1e52dd8c"
|
||||
staffId: "e7f8a9b0-c1d2-4e5f-a6b7-c8d9e0f1a2b3"
|
||||
staffName: "Test Staff"
|
||||
documentId: "2eb0f5e0-b5f7-4ec2-8994-ae5b12ec8f57"
|
||||
status: PENDING
|
||||
documentUrl: "https://storage.googleapis.com/krow-workforce-dev/docs/staff-7/id-copy.png"
|
||||
verificationId: "verif_staff7_id_001"
|
||||
}
|
||||
)
|
||||
|
||||
# Certificates
|
||||
certificate_1: certificate_insert(
|
||||
@@ -618,6 +855,34 @@ mutation seedAll @transaction {
|
||||
certificateNumber: "RBS-STAFF2-1103"
|
||||
}
|
||||
)
|
||||
certificate_4: certificate_insert(
|
||||
data: {
|
||||
id: "67b9ec44-6f9b-4f3a-9c8d-23f370883a90"
|
||||
staffId: "e7f8a9b0-c1d2-4e5f-a6b7-c8d9e0f1a2b3"
|
||||
certificationType: BACKGROUND_CHECK
|
||||
name: "Background Check Clearance"
|
||||
status: CURRENT
|
||||
issuer: "Checkr"
|
||||
fileUrl: "https://storage.googleapis.com/krow-workforce-dev/certs/staff-7/background.pdf"
|
||||
validationStatus: APPROVED
|
||||
certificateNumber: "BG-STAFF7-2026"
|
||||
expiry: "2027-02-20T00:00:00Z"
|
||||
}
|
||||
)
|
||||
certificate_5: certificate_insert(
|
||||
data: {
|
||||
id: "67b9ec44-6f9b-4f3a-9c8d-23f370883a91"
|
||||
staffId: "e7f8a9b0-c1d2-4e5f-a6b7-c8d9e0f1a2b3"
|
||||
certificationType: FOOD_HANDLER
|
||||
name: "Food Handler Card"
|
||||
status: CURRENT
|
||||
issuer: "ServSafe"
|
||||
fileUrl: "https://storage.googleapis.com/krow-workforce-dev/certs/staff-7/food-handler.pdf"
|
||||
validationStatus: AI_VERIFIED
|
||||
certificateNumber: "FH-STAFF7-2204"
|
||||
expiry: "2026-12-30T00:00:00Z"
|
||||
}
|
||||
)
|
||||
|
||||
# Orders (20 total)
|
||||
order_01: order_insert(
|
||||
@@ -900,6 +1165,90 @@ mutation seedAll @transaction {
|
||||
total: 224
|
||||
}
|
||||
)
|
||||
order_21: order_insert(
|
||||
data: {
|
||||
id: "f201e540-70cd-4e49-b3b1-4c85df9a2101"
|
||||
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
|
||||
businessId: "ef69e942-d6e5-48e5-a8bc-69d3faa63b2f"
|
||||
orderType: ONE_TIME
|
||||
status: PARTIAL_STAFFED
|
||||
eventName: "Client Demo Breakfast Support"
|
||||
teamHubId: "22a0b119-e6dc-4011-9043-d857cd4c12f3"
|
||||
date: "2026-03-04T05:00:00Z"
|
||||
requested: 2
|
||||
total: 320
|
||||
}
|
||||
)
|
||||
order_22: order_insert(
|
||||
data: {
|
||||
id: "f201e540-70cd-4e49-b3b1-4c85df9a2102"
|
||||
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
|
||||
businessId: "ef69e942-d6e5-48e5-a8bc-69d3faa63b2f"
|
||||
orderType: ONE_TIME
|
||||
status: PARTIAL_STAFFED
|
||||
eventName: "Conference Lounge Coverage"
|
||||
teamHubId: "9c8eb9c6-c186-4d55-877e-35be852c3e86"
|
||||
date: "2026-03-05T05:00:00Z"
|
||||
requested: 3
|
||||
total: 624
|
||||
}
|
||||
)
|
||||
order_23: order_insert(
|
||||
data: {
|
||||
id: "f201e540-70cd-4e49-b3b1-4c85df9a2103"
|
||||
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
|
||||
businessId: "ef69e942-d6e5-48e5-a8bc-69d3faa63b2f"
|
||||
orderType: ONE_TIME
|
||||
status: FULLY_STAFFED
|
||||
eventName: "Executive Lunch Prep"
|
||||
teamHubId: "75c70d83-1680-4b28-ab61-2fe64a74fc5f"
|
||||
date: "2026-03-06T05:00:00Z"
|
||||
requested: 2
|
||||
total: 288
|
||||
}
|
||||
)
|
||||
order_24: order_insert(
|
||||
data: {
|
||||
id: "f201e540-70cd-4e49-b3b1-4c85df9a2104"
|
||||
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
|
||||
businessId: "ef69e942-d6e5-48e5-a8bc-69d3faa63b2f"
|
||||
orderType: ONE_TIME
|
||||
status: COMPLETED
|
||||
eventName: "Late Shift Cleanup"
|
||||
teamHubId: "22a0b119-e6dc-4011-9043-d857cd4c12f3"
|
||||
date: "2026-03-03T05:00:00Z"
|
||||
requested: 1
|
||||
total: 208
|
||||
}
|
||||
)
|
||||
order_25: order_insert(
|
||||
data: {
|
||||
id: "f201e540-70cd-4e49-b3b1-4c85df9a2105"
|
||||
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
|
||||
businessId: "ef69e942-d6e5-48e5-a8bc-69d3faa63b2f"
|
||||
orderType: ONE_TIME
|
||||
status: POSTED
|
||||
eventName: "Sunday Brunch Standby"
|
||||
teamHubId: "9c8eb9c6-c186-4d55-877e-35be852c3e86"
|
||||
date: "2026-03-08T05:00:00Z"
|
||||
requested: 1
|
||||
total: 120
|
||||
}
|
||||
)
|
||||
order_26: order_insert(
|
||||
data: {
|
||||
id: "f201e540-70cd-4e49-b3b1-4c85df9a2106"
|
||||
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
|
||||
businessId: "ef69e942-d6e5-48e5-a8bc-69d3faa63b2f"
|
||||
orderType: ONE_TIME
|
||||
status: FULLY_STAFFED
|
||||
eventName: "Security Night Rotation"
|
||||
teamHubId: "75c70d83-1680-4b28-ab61-2fe64a74fc5f"
|
||||
date: "2026-03-10T05:00:00Z"
|
||||
requested: 1
|
||||
total: 280
|
||||
}
|
||||
)
|
||||
|
||||
# Shifts (1 per order)
|
||||
shift_01: shift_insert(
|
||||
@@ -1362,6 +1711,148 @@ mutation seedAll @transaction {
|
||||
filled: 0
|
||||
}
|
||||
)
|
||||
shift_21: shift_insert(
|
||||
data: {
|
||||
id: "ea729213-1652-4e4b-95cb-a7d5c1a1e301"
|
||||
title: "Client Demo Breakfast Support Shift"
|
||||
orderId: "f201e540-70cd-4e49-b3b1-4c85df9a2101"
|
||||
date: "2026-03-04T05:00:00Z"
|
||||
startTime: "2026-03-04T14:00:00Z"
|
||||
endTime: "2026-03-04T22:00:00Z"
|
||||
hours: 8
|
||||
cost: 320
|
||||
locationAddress: "5000 San Jose Street, Granada Hills, CA, USA"
|
||||
city: "Los Angeles"
|
||||
state: "CA"
|
||||
street: "San Jose Street"
|
||||
country: "US"
|
||||
placeId: "Eiw1MDAwIFNhbiBKb3NlIFN0cmVldCwgR3JhbmFkYSBIaWxscywgQ0EsIFVTQSIuKiwKFAoSCYNJZBTdmsKAEddGOfBj8LvTEhQKEglnNXI0zZrCgBEjR6om62lcVw"
|
||||
latitude: 34.2611486
|
||||
longitude: -118.5010287
|
||||
status: IN_PROGRESS
|
||||
workersNeeded: 2
|
||||
filled: 1
|
||||
filledAt: "2026-03-03T20:00:00Z"
|
||||
}
|
||||
)
|
||||
shift_22: shift_insert(
|
||||
data: {
|
||||
id: "ea729213-1652-4e4b-95cb-a7d5c1a1e302"
|
||||
title: "Conference Lounge Coverage Shift"
|
||||
orderId: "f201e540-70cd-4e49-b3b1-4c85df9a2102"
|
||||
date: "2026-03-05T05:00:00Z"
|
||||
startTime: "2026-03-05T15:00:00Z"
|
||||
endTime: "2026-03-05T23:00:00Z"
|
||||
hours: 8
|
||||
cost: 624
|
||||
locationAddress: "4000 San Jose Street, Granada Hills, CA, USA"
|
||||
city: "Los Angeles"
|
||||
state: "CA"
|
||||
street: "San Jose Street"
|
||||
country: "US"
|
||||
placeId: "Eiw0MDAwIFNhbiBKb3NlIFN0cmVldCwgR3JhbmFkYSBIaWxscywgQ0EsIFVTQSIuKiwKFAoSCYNJZBTdmsKAEddGOfBj8LvTEhQKEglnNXI0zZrCgBEjR6om62lcVw"
|
||||
latitude: 34.2611486
|
||||
longitude: -118.5010287
|
||||
status: OPEN
|
||||
workersNeeded: 3
|
||||
filled: 2
|
||||
}
|
||||
)
|
||||
shift_23: shift_insert(
|
||||
data: {
|
||||
id: "ea729213-1652-4e4b-95cb-a7d5c1a1e303"
|
||||
title: "Executive Lunch Prep Shift"
|
||||
orderId: "f201e540-70cd-4e49-b3b1-4c85df9a2103"
|
||||
date: "2026-03-06T05:00:00Z"
|
||||
startTime: "2026-03-06T16:00:00Z"
|
||||
endTime: "2026-03-06T22:00:00Z"
|
||||
hours: 6
|
||||
cost: 288
|
||||
locationAddress: "6800 San Jose Street, Granada Hills, CA, USA"
|
||||
city: "Los Angeles"
|
||||
state: "CA"
|
||||
street: "San Jose Street"
|
||||
country: "US"
|
||||
placeId: "Eiw2ODAwIFNhbiBKb3NlIFN0cmVldCwgR3JhbmFkYSBIaWxscywgQ0EsIFVTQSIuKiwKFAoSCYNJZBTdmsKAEddGOfBj8LvTEhQKEglnNXI0zZrCgBEjR6om62lcVw"
|
||||
latitude: 34.2611486
|
||||
longitude: -118.5010287
|
||||
status: FILLED
|
||||
workersNeeded: 2
|
||||
filled: 2
|
||||
filledAt: "2026-03-04T18:10:00Z"
|
||||
}
|
||||
)
|
||||
shift_24: shift_insert(
|
||||
data: {
|
||||
id: "ea729213-1652-4e4b-95cb-a7d5c1a1e304"
|
||||
title: "Late Shift Cleanup Shift"
|
||||
orderId: "f201e540-70cd-4e49-b3b1-4c85df9a2104"
|
||||
date: "2026-03-03T05:00:00Z"
|
||||
startTime: "2026-03-03T14:00:00Z"
|
||||
endTime: "2026-03-03T22:00:00Z"
|
||||
hours: 8
|
||||
cost: 208
|
||||
locationAddress: "5000 San Jose Street, Granada Hills, CA, USA"
|
||||
city: "Los Angeles"
|
||||
state: "CA"
|
||||
street: "San Jose Street"
|
||||
country: "US"
|
||||
placeId: "Eiw1MDAwIFNhbiBKb3NlIFN0cmVldCwgR3JhbmFkYSBIaWxscywgQ0EsIFVTQSIuKiwKFAoSCYNJZBTdmsKAEddGOfBj8LvTEhQKEglnNXI0zZrCgBEjR6om62lcVw"
|
||||
latitude: 34.2611486
|
||||
longitude: -118.5010287
|
||||
status: COMPLETED
|
||||
workersNeeded: 1
|
||||
filled: 1
|
||||
filledAt: "2026-03-02T22:30:00Z"
|
||||
}
|
||||
)
|
||||
shift_25: shift_insert(
|
||||
data: {
|
||||
id: "ea729213-1652-4e4b-95cb-a7d5c1a1e305"
|
||||
title: "Sunday Brunch Standby Shift"
|
||||
orderId: "f201e540-70cd-4e49-b3b1-4c85df9a2105"
|
||||
date: "2026-03-08T05:00:00Z"
|
||||
startTime: "2026-03-08T15:00:00Z"
|
||||
endTime: "2026-03-08T20:00:00Z"
|
||||
hours: 5
|
||||
cost: 120
|
||||
locationAddress: "4000 San Jose Street, Granada Hills, CA, USA"
|
||||
city: "Los Angeles"
|
||||
state: "CA"
|
||||
street: "San Jose Street"
|
||||
country: "US"
|
||||
placeId: "Eiw0MDAwIFNhbiBKb3NlIFN0cmVldCwgR3JhbmFkYSBIaWxscywgQ0EsIFVTQSIuKiwKFAoSCYNJZBTdmsKAEddGOfBj8LvTEhQKEglnNXI0zZrCgBEjR6om62lcVw"
|
||||
latitude: 34.2611486
|
||||
longitude: -118.5010287
|
||||
status: OPEN
|
||||
workersNeeded: 1
|
||||
filled: 0
|
||||
}
|
||||
)
|
||||
shift_26: shift_insert(
|
||||
data: {
|
||||
id: "ea729213-1652-4e4b-95cb-a7d5c1a1e306"
|
||||
title: "Security Night Rotation Shift"
|
||||
orderId: "f201e540-70cd-4e49-b3b1-4c85df9a2106"
|
||||
date: "2026-03-10T05:00:00Z"
|
||||
startTime: "2026-03-10T14:00:00Z"
|
||||
endTime: "2026-03-11T00:00:00Z"
|
||||
hours: 10
|
||||
cost: 280
|
||||
locationAddress: "6800 San Jose Street, Granada Hills, CA, USA"
|
||||
city: "Los Angeles"
|
||||
state: "CA"
|
||||
street: "San Jose Street"
|
||||
country: "US"
|
||||
placeId: "Eiw2ODAwIFNhbiBKb3NlIFN0cmVldCwgR3JhbmFkYSBIaWxscywgQ0EsIFVTQSIuKiwKFAoSCYNJZBTdmsKAEddGOfBj8LvTEhQKEglnNXI0zZrCgBEjR6om62lcVw"
|
||||
latitude: 34.2611486
|
||||
longitude: -118.5010287
|
||||
status: FILLED
|
||||
workersNeeded: 1
|
||||
filled: 1
|
||||
filledAt: "2026-03-09T19:00:00Z"
|
||||
}
|
||||
)
|
||||
|
||||
# Shift Roles (1 per shift)
|
||||
shift_role_01: shiftRole_insert(
|
||||
@@ -1644,6 +2135,90 @@ mutation seedAll @transaction {
|
||||
totalValue: 224
|
||||
}
|
||||
)
|
||||
shift_role_21: shiftRole_insert(
|
||||
data: {
|
||||
id: "360616bf-8083-4dff-8d22-82380304d901"
|
||||
shiftId: "ea729213-1652-4e4b-95cb-a7d5c1a1e301"
|
||||
roleId: "73fdb09b-ecbd-402e-8eb4-e7d79237d017"
|
||||
count: 2
|
||||
assigned: 1
|
||||
startTime: "2026-03-04T14:00:00Z"
|
||||
endTime: "2026-03-04T22:00:00Z"
|
||||
hours: 8
|
||||
breakType: MIN_30
|
||||
totalValue: 320
|
||||
}
|
||||
)
|
||||
shift_role_22: shiftRole_insert(
|
||||
data: {
|
||||
id: "360616bf-8083-4dff-8d22-82380304d902"
|
||||
shiftId: "ea729213-1652-4e4b-95cb-a7d5c1a1e302"
|
||||
roleId: "7de956ce-743b-4271-b826-73313a5f07f5"
|
||||
count: 3
|
||||
assigned: 2
|
||||
startTime: "2026-03-05T15:00:00Z"
|
||||
endTime: "2026-03-05T23:00:00Z"
|
||||
hours: 8
|
||||
breakType: MIN_30
|
||||
totalValue: 624
|
||||
}
|
||||
)
|
||||
shift_role_23: shiftRole_insert(
|
||||
data: {
|
||||
id: "360616bf-8083-4dff-8d22-82380304d903"
|
||||
shiftId: "ea729213-1652-4e4b-95cb-a7d5c1a1e303"
|
||||
roleId: "e51f3553-f2ee-400b-91e6-92b534239697"
|
||||
count: 2
|
||||
assigned: 2
|
||||
startTime: "2026-03-06T16:00:00Z"
|
||||
endTime: "2026-03-06T22:00:00Z"
|
||||
hours: 6
|
||||
breakType: MIN_15
|
||||
totalValue: 288
|
||||
}
|
||||
)
|
||||
shift_role_24: shiftRole_insert(
|
||||
data: {
|
||||
id: "360616bf-8083-4dff-8d22-82380304d904"
|
||||
shiftId: "ea729213-1652-4e4b-95cb-a7d5c1a1e304"
|
||||
roleId: "7de956ce-743b-4271-b826-73313a5f07f5"
|
||||
count: 1
|
||||
assigned: 1
|
||||
startTime: "2026-03-03T14:00:00Z"
|
||||
endTime: "2026-03-03T22:00:00Z"
|
||||
hours: 8
|
||||
breakType: MIN_30
|
||||
totalValue: 208
|
||||
}
|
||||
)
|
||||
shift_role_25: shiftRole_insert(
|
||||
data: {
|
||||
id: "360616bf-8083-4dff-8d22-82380304d905"
|
||||
shiftId: "ea729213-1652-4e4b-95cb-a7d5c1a1e305"
|
||||
roleId: "e51f3553-f2ee-400b-91e6-92b534239697"
|
||||
count: 1
|
||||
assigned: 0
|
||||
startTime: "2026-03-08T15:00:00Z"
|
||||
endTime: "2026-03-08T20:00:00Z"
|
||||
hours: 5
|
||||
breakType: MIN_15
|
||||
totalValue: 120
|
||||
}
|
||||
)
|
||||
shift_role_26: shiftRole_insert(
|
||||
data: {
|
||||
id: "360616bf-8083-4dff-8d22-82380304d906"
|
||||
shiftId: "ea729213-1652-4e4b-95cb-a7d5c1a1e306"
|
||||
roleId: "67ab1dcb-5b54-4dd9-aeb5-9cc58bdda0ed"
|
||||
count: 1
|
||||
assigned: 1
|
||||
startTime: "2026-03-10T14:00:00Z"
|
||||
endTime: "2026-03-11T00:00:00Z"
|
||||
hours: 10
|
||||
breakType: MIN_30
|
||||
totalValue: 280
|
||||
}
|
||||
)
|
||||
|
||||
# Applications
|
||||
application_01: application_insert(
|
||||
@@ -1836,6 +2411,80 @@ mutation seedAll @transaction {
|
||||
origin: STAFF
|
||||
}
|
||||
)
|
||||
application_20: application_insert(
|
||||
data: {
|
||||
id: "b8c4b723-346d-4bcd-9667-35944ba5dbbe"
|
||||
shiftId: "ea729213-1652-4e4b-95cb-a7d5c1a1e301"
|
||||
staffId: "e7f8a9b0-c1d2-4e5f-a6b7-c8d9e0f1a2b3"
|
||||
roleId: "73fdb09b-ecbd-402e-8eb4-e7d79237d017"
|
||||
status: CHECKED_IN
|
||||
checkInTime: "2026-03-04T14:02:00Z"
|
||||
origin: STAFF
|
||||
}
|
||||
)
|
||||
application_21: application_insert(
|
||||
data: {
|
||||
id: "b8c4b723-346d-4bcd-9667-35944ba5dbbf"
|
||||
shiftId: "ea729213-1652-4e4b-95cb-a7d5c1a1e304"
|
||||
staffId: "e7f8a9b0-c1d2-4e5f-a6b7-c8d9e0f1a2b3"
|
||||
roleId: "7de956ce-743b-4271-b826-73313a5f07f5"
|
||||
status: COMPLETED
|
||||
checkInTime: "2026-03-03T14:05:00Z"
|
||||
checkOutTime: "2026-03-03T22:01:00Z"
|
||||
origin: STAFF
|
||||
}
|
||||
)
|
||||
application_22: application_insert(
|
||||
data: {
|
||||
id: "b8c4b723-346d-4bcd-9667-35944ba5dbc0"
|
||||
shiftId: "ea729213-1652-4e4b-95cb-a7d5c1a1e303"
|
||||
staffId: "633df3ce-b92c-473f-90d8-38dd027fdf57"
|
||||
roleId: "e51f3553-f2ee-400b-91e6-92b534239697"
|
||||
status: CONFIRMED
|
||||
origin: STAFF
|
||||
}
|
||||
)
|
||||
application_23: application_insert(
|
||||
data: {
|
||||
id: "b8c4b723-346d-4bcd-9667-35944ba5dbc1"
|
||||
shiftId: "ea729213-1652-4e4b-95cb-a7d5c1a1e302"
|
||||
staffId: "9631581a-1601-4e06-8e5e-600e9f305bcf"
|
||||
roleId: "7de956ce-743b-4271-b826-73313a5f07f5"
|
||||
status: LATE
|
||||
checkInTime: "2026-03-05T15:25:00Z"
|
||||
origin: STAFF
|
||||
}
|
||||
)
|
||||
application_24: application_insert(
|
||||
data: {
|
||||
id: "b8c4b723-346d-4bcd-9667-35944ba5dbc2"
|
||||
shiftId: "ea729213-1652-4e4b-95cb-a7d5c1a1e302"
|
||||
staffId: "2b678a6d-b8cd-4d5e-95ae-f35e4569f92c"
|
||||
roleId: "7de956ce-743b-4271-b826-73313a5f07f5"
|
||||
status: NO_SHOW
|
||||
origin: STAFF
|
||||
}
|
||||
)
|
||||
application_25: application_insert(
|
||||
data: {
|
||||
id: "b8c4b723-346d-4bcd-9667-35944ba5dbc3"
|
||||
shiftId: "ea729213-1652-4e4b-95cb-a7d5c1a1e306"
|
||||
staffId: "e7f8a9b0-c1d2-4e5f-a6b7-c8d9e0f1a2b3"
|
||||
roleId: "67ab1dcb-5b54-4dd9-aeb5-9cc58bdda0ed"
|
||||
status: CONFIRMED
|
||||
origin: STAFF
|
||||
}
|
||||
)
|
||||
application_26: application_insert(
|
||||
data: {
|
||||
id: "b8c4b723-346d-4bcd-9667-35944ba5dbc4"
|
||||
shiftId: "ea729213-1652-4e4b-95cb-a7d5c1a1e303"
|
||||
staffId: "56d7178c-f4ab-4c50-9b1f-d6efe25ba50b"
|
||||
roleId: "e51f3553-f2ee-400b-91e6-92b534239697"
|
||||
status: CONFIRMED
|
||||
origin: STAFF
|
||||
}
|
||||
)
|
||||
|
||||
# Invoices (for completed orders)
|
||||
invoice_01: invoice_insert(
|
||||
@@ -2030,6 +2679,91 @@ mutation seedAll @transaction {
|
||||
chargesCount: 1
|
||||
}
|
||||
)
|
||||
invoice_13: invoice_insert(
|
||||
data: {
|
||||
id: "c23f3ed2-7fa1-43f5-88e9-4227e34cb5f1"
|
||||
status: PENDING
|
||||
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
|
||||
businessId: "ef69e942-d6e5-48e5-a8bc-69d3faa63b2f"
|
||||
orderId: "f201e540-70cd-4e49-b3b1-4c85df9a2101"
|
||||
shiftId: "ea729213-1652-4e4b-95cb-a7d5c1a1e301"
|
||||
paymentTerms: NET_30
|
||||
invoiceNumber: "INV-2026-0013"
|
||||
issueDate: "2026-03-04T05:00:00Z"
|
||||
dueDate: "2026-04-03T05:00:00Z"
|
||||
amount: 320
|
||||
staffCount: 2
|
||||
chargesCount: 1
|
||||
}
|
||||
)
|
||||
invoice_14: invoice_insert(
|
||||
data: {
|
||||
id: "c23f3ed2-7fa1-43f5-88e9-4227e34cb5f2"
|
||||
status: OVERDUE
|
||||
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
|
||||
businessId: "ef69e942-d6e5-48e5-a8bc-69d3faa63b2f"
|
||||
orderId: "f201e540-70cd-4e49-b3b1-4c85df9a2102"
|
||||
shiftId: "ea729213-1652-4e4b-95cb-a7d5c1a1e302"
|
||||
paymentTerms: NET_30
|
||||
invoiceNumber: "INV-2026-0014"
|
||||
issueDate: "2026-02-20T05:00:00Z"
|
||||
dueDate: "2026-03-01T05:00:00Z"
|
||||
amount: 624
|
||||
staffCount: 3
|
||||
chargesCount: 1
|
||||
}
|
||||
)
|
||||
invoice_15: invoice_insert(
|
||||
data: {
|
||||
id: "c23f3ed2-7fa1-43f5-88e9-4227e34cb5f3"
|
||||
status: APPROVED
|
||||
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
|
||||
businessId: "ef69e942-d6e5-48e5-a8bc-69d3faa63b2f"
|
||||
orderId: "f201e540-70cd-4e49-b3b1-4c85df9a2103"
|
||||
shiftId: "ea729213-1652-4e4b-95cb-a7d5c1a1e303"
|
||||
paymentTerms: NET_30
|
||||
invoiceNumber: "INV-2026-0015"
|
||||
issueDate: "2026-03-06T05:00:00Z"
|
||||
dueDate: "2026-04-05T05:00:00Z"
|
||||
amount: 288
|
||||
staffCount: 2
|
||||
chargesCount: 1
|
||||
}
|
||||
)
|
||||
invoice_16: invoice_insert(
|
||||
data: {
|
||||
id: "c23f3ed2-7fa1-43f5-88e9-4227e34cb5f4"
|
||||
status: PAID
|
||||
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
|
||||
businessId: "ef69e942-d6e5-48e5-a8bc-69d3faa63b2f"
|
||||
orderId: "f201e540-70cd-4e49-b3b1-4c85df9a2104"
|
||||
shiftId: "ea729213-1652-4e4b-95cb-a7d5c1a1e304"
|
||||
paymentTerms: NET_30
|
||||
invoiceNumber: "INV-2026-0016"
|
||||
issueDate: "2026-03-03T05:00:00Z"
|
||||
dueDate: "2026-04-02T05:00:00Z"
|
||||
amount: 208
|
||||
staffCount: 1
|
||||
chargesCount: 1
|
||||
}
|
||||
)
|
||||
invoice_17: invoice_insert(
|
||||
data: {
|
||||
id: "c23f3ed2-7fa1-43f5-88e9-4227e34cb5f5"
|
||||
status: PENDING_REVIEW
|
||||
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
|
||||
businessId: "ef69e942-d6e5-48e5-a8bc-69d3faa63b2f"
|
||||
orderId: "f201e540-70cd-4e49-b3b1-4c85df9a2106"
|
||||
shiftId: "ea729213-1652-4e4b-95cb-a7d5c1a1e306"
|
||||
paymentTerms: NET_30
|
||||
invoiceNumber: "INV-2026-0017"
|
||||
issueDate: "2026-03-10T05:00:00Z"
|
||||
dueDate: "2026-04-09T05:00:00Z"
|
||||
amount: 280
|
||||
staffCount: 1
|
||||
chargesCount: 1
|
||||
}
|
||||
)
|
||||
|
||||
# Recent Payments (only for PAID invoices)
|
||||
recent_payment_01: recentPayment_insert(
|
||||
@@ -2062,6 +2796,46 @@ mutation seedAll @transaction {
|
||||
invoiceId: "ba0529be-7906-417f-8ec7-c866d0633fee"
|
||||
}
|
||||
)
|
||||
recent_payment_04: recentPayment_insert(
|
||||
data: {
|
||||
id: "4d45192e-34fe-4e07-a4f9-708e7591a9b6"
|
||||
workedTime: "8h"
|
||||
status: PAID
|
||||
staffId: "e7f8a9b0-c1d2-4e5f-a6b7-c8d9e0f1a2b3"
|
||||
applicationId: "b8c4b723-346d-4bcd-9667-35944ba5dbbf"
|
||||
invoiceId: "c23f3ed2-7fa1-43f5-88e9-4227e34cb5f4"
|
||||
}
|
||||
)
|
||||
recent_payment_05: recentPayment_insert(
|
||||
data: {
|
||||
id: "4d45192e-34fe-4e07-a4f9-708e7591a9b7"
|
||||
workedTime: "8h"
|
||||
status: PENDING
|
||||
staffId: "e7f8a9b0-c1d2-4e5f-a6b7-c8d9e0f1a2b3"
|
||||
applicationId: "b8c4b723-346d-4bcd-9667-35944ba5dbbe"
|
||||
invoiceId: "c23f3ed2-7fa1-43f5-88e9-4227e34cb5f1"
|
||||
}
|
||||
)
|
||||
recent_payment_06: recentPayment_insert(
|
||||
data: {
|
||||
id: "4d45192e-34fe-4e07-a4f9-708e7591a9b8"
|
||||
workedTime: "6h"
|
||||
status: PENDING
|
||||
staffId: "633df3ce-b92c-473f-90d8-38dd027fdf57"
|
||||
applicationId: "b8c4b723-346d-4bcd-9667-35944ba5dbc0"
|
||||
invoiceId: "c23f3ed2-7fa1-43f5-88e9-4227e34cb5f3"
|
||||
}
|
||||
)
|
||||
recent_payment_07: recentPayment_insert(
|
||||
data: {
|
||||
id: "4d45192e-34fe-4e07-a4f9-708e7591a9b9"
|
||||
workedTime: "6h"
|
||||
status: PENDING
|
||||
staffId: "56d7178c-f4ab-4c50-9b1f-d6efe25ba50b"
|
||||
applicationId: "b8c4b723-346d-4bcd-9667-35944ba5dbc4"
|
||||
invoiceId: "c23f3ed2-7fa1-43f5-88e9-4227e34cb5f3"
|
||||
}
|
||||
)
|
||||
|
||||
# Attire Options (Required)
|
||||
attire_1: attireOption_insert(
|
||||
@@ -2220,5 +2994,54 @@ mutation seedAll @transaction {
|
||||
vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a"
|
||||
}
|
||||
)
|
||||
staff_attire_1: staffAttire_insert(
|
||||
data: {
|
||||
staffId: "e7f8a9b0-c1d2-4e5f-a6b7-c8d9e0f1a2b3"
|
||||
attireOptionId: "4bce6592-e38e-4d90-a478-d1ce0f286146"
|
||||
verificationStatus: APPROVED
|
||||
verificationPhotoUrl: "https://storage.googleapis.com/krow-workforce-dev/attire/staff-7/non-slip-shoes.jpg"
|
||||
verificationId: "attire_verif_staff7_001"
|
||||
verifiedAt: "2026-03-01T17:00:00Z"
|
||||
}
|
||||
)
|
||||
staff_attire_2: staffAttire_insert(
|
||||
data: {
|
||||
staffId: "e7f8a9b0-c1d2-4e5f-a6b7-c8d9e0f1a2b3"
|
||||
attireOptionId: "786e9761-b398-42bd-b363-91a40938864e"
|
||||
verificationStatus: APPROVED
|
||||
verificationPhotoUrl: "https://storage.googleapis.com/krow-workforce-dev/attire/staff-7/black-pants.jpg"
|
||||
verificationId: "attire_verif_staff7_002"
|
||||
verifiedAt: "2026-03-01T17:05:00Z"
|
||||
}
|
||||
)
|
||||
staff_attire_3: staffAttire_insert(
|
||||
data: {
|
||||
staffId: "e7f8a9b0-c1d2-4e5f-a6b7-c8d9e0f1a2b3"
|
||||
attireOptionId: "bbff61b3-3f99-4637-9a2f-1d4c6fa61517"
|
||||
verificationStatus: PROCESSING
|
||||
verificationPhotoUrl: "https://storage.googleapis.com/krow-workforce-dev/attire/staff-7/white-button-up.jpg"
|
||||
verificationId: "attire_verif_staff7_003"
|
||||
}
|
||||
)
|
||||
staff_attire_4: staffAttire_insert(
|
||||
data: {
|
||||
staffId: "633df3ce-b92c-473f-90d8-38dd027fdf57"
|
||||
attireOptionId: "4bce6592-e38e-4d90-a478-d1ce0f286146"
|
||||
verificationStatus: APPROVED
|
||||
verificationPhotoUrl: "https://storage.googleapis.com/krow-workforce-dev/attire/staff-1/non-slip-shoes.jpg"
|
||||
verificationId: "attire_verif_staff1_001"
|
||||
verifiedAt: "2026-02-21T16:20:00Z"
|
||||
}
|
||||
)
|
||||
staff_attire_5: staffAttire_insert(
|
||||
data: {
|
||||
staffId: "633df3ce-b92c-473f-90d8-38dd027fdf57"
|
||||
attireOptionId: "17b135e6-b8f0-4541-b12b-505e95de31ef"
|
||||
verificationStatus: APPROVED
|
||||
verificationPhotoUrl: "https://storage.googleapis.com/krow-workforce-dev/attire/staff-1/black-socks.jpg"
|
||||
verificationId: "attire_verif_staff1_002"
|
||||
verifiedAt: "2026-02-21T16:23:00Z"
|
||||
}
|
||||
)
|
||||
}
|
||||
#v.3
|
||||
#v.4
|
||||
|
||||
13
backend/query-api/Dockerfile
Normal file
13
backend/query-api/Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
COPY src ./src
|
||||
|
||||
ENV PORT=8080
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["node", "src/server.js"]
|
||||
3039
backend/query-api/package-lock.json
generated
Normal file
3039
backend/query-api/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
backend/query-api/package.json
Normal file
23
backend/query-api/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@krow/query-api",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node src/server.js",
|
||||
"test": "node --test"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.21.2",
|
||||
"firebase-admin": "^13.0.2",
|
||||
"pg": "^8.20.0",
|
||||
"pino": "^9.6.0",
|
||||
"pino-http": "^10.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"supertest": "^7.0.0"
|
||||
}
|
||||
}
|
||||
32
backend/query-api/src/app.js
Normal file
32
backend/query-api/src/app.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import express from 'express';
|
||||
import pino from 'pino';
|
||||
import pinoHttp from 'pino-http';
|
||||
import { requestContext } from './middleware/request-context.js';
|
||||
import { errorHandler, notFoundHandler } from './middleware/error-handler.js';
|
||||
import { healthRouter } from './routes/health.js';
|
||||
import { createQueryRouter } from './routes/query.js';
|
||||
import { createMobileQueryRouter } from './routes/mobile.js';
|
||||
|
||||
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
|
||||
|
||||
export function createApp(options = {}) {
|
||||
const app = express();
|
||||
|
||||
app.use(requestContext);
|
||||
app.use(
|
||||
pinoHttp({
|
||||
logger,
|
||||
customProps: (req) => ({ requestId: req.requestId }),
|
||||
})
|
||||
);
|
||||
app.use(express.json({ limit: '2mb' }));
|
||||
|
||||
app.use(healthRouter);
|
||||
app.use('/query', createQueryRouter(options.queryService));
|
||||
app.use('/query', createMobileQueryRouter(options.mobileQueryService));
|
||||
|
||||
app.use(notFoundHandler);
|
||||
app.use(errorHandler);
|
||||
|
||||
return app;
|
||||
}
|
||||
41
backend/query-api/src/data/faqs.js
Normal file
41
backend/query-api/src/data/faqs.js
Normal file
@@ -0,0 +1,41 @@
|
||||
export const FAQ_CATEGORIES = [
|
||||
{
|
||||
category: 'Getting Started',
|
||||
items: [
|
||||
{
|
||||
question: 'How do I complete my worker profile?',
|
||||
answer: 'Finish your personal info, preferred locations, experience, emergency contact, attire, and tax forms so shift applications and clock-in become available.',
|
||||
},
|
||||
{
|
||||
question: 'Why can I not apply to shifts yet?',
|
||||
answer: 'The worker profile must be complete before the platform allows applications and shift acceptance. Missing sections are returned by the profile completion endpoints.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'Shifts And Attendance',
|
||||
items: [
|
||||
{
|
||||
question: 'How does clock-in work?',
|
||||
answer: 'Clock-in validates that you are assigned to the shift, near the configured hub geofence, and using the expected clock-in source such as near-field communication when required.',
|
||||
},
|
||||
{
|
||||
question: 'What happens if I request a swap?',
|
||||
answer: 'The assignment moves to swap-requested status so operations can refill the shift while keeping an audit trail of the original assignment.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'Payments And Compliance',
|
||||
items: [
|
||||
{
|
||||
question: 'When do I see my earnings?',
|
||||
answer: 'Completed and processed time records appear in the worker payments summary, history, and time-card screens after attendance closes and payment processing runs.',
|
||||
},
|
||||
{
|
||||
question: 'How are documents and certificates verified?',
|
||||
answer: 'Uploads create verification jobs that run automatic checks first and then allow manual review when confidence is low or a provider is unavailable.',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
26
backend/query-api/src/lib/errors.js
Normal file
26
backend/query-api/src/lib/errors.js
Normal file
@@ -0,0 +1,26 @@
|
||||
export class AppError extends Error {
|
||||
constructor(code, message, status = 400, details = {}) {
|
||||
super(message);
|
||||
this.name = 'AppError';
|
||||
this.code = code;
|
||||
this.status = status;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
export function toErrorEnvelope(error, requestId) {
|
||||
const status = error?.status && Number.isInteger(error.status) ? error.status : 500;
|
||||
const code = error?.code || 'INTERNAL_ERROR';
|
||||
const message = error?.message || 'Unexpected error';
|
||||
const details = error?.details || {};
|
||||
|
||||
return {
|
||||
status,
|
||||
body: {
|
||||
code,
|
||||
message,
|
||||
details,
|
||||
requestId,
|
||||
},
|
||||
};
|
||||
}
|
||||
45
backend/query-api/src/middleware/auth.js
Normal file
45
backend/query-api/src/middleware/auth.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import { AppError } from '../lib/errors.js';
|
||||
import { can } from '../services/policy.js';
|
||||
import { verifyFirebaseToken } from '../services/firebase-auth.js';
|
||||
|
||||
function getBearerToken(header) {
|
||||
if (!header) return null;
|
||||
const [scheme, token] = header.split(' ');
|
||||
if (!scheme || scheme.toLowerCase() !== 'bearer' || !token) return null;
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function requireAuth(req, _res, next) {
|
||||
try {
|
||||
const token = getBearerToken(req.get('Authorization'));
|
||||
if (!token) {
|
||||
throw new AppError('UNAUTHENTICATED', 'Missing bearer token', 401);
|
||||
}
|
||||
|
||||
if (process.env.AUTH_BYPASS === 'true') {
|
||||
req.actor = { uid: 'test-user', email: 'test@krow.local', role: 'TEST' };
|
||||
return next();
|
||||
}
|
||||
|
||||
const decoded = await verifyFirebaseToken(token);
|
||||
req.actor = {
|
||||
uid: decoded.uid,
|
||||
email: decoded.email || null,
|
||||
role: decoded.role || null,
|
||||
};
|
||||
|
||||
return next();
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) return next(error);
|
||||
return next(new AppError('UNAUTHENTICATED', 'Token verification failed', 401));
|
||||
}
|
||||
}
|
||||
|
||||
export function requirePolicy(action, resource) {
|
||||
return (req, _res, next) => {
|
||||
if (!can(action, resource, req.actor)) {
|
||||
return next(new AppError('FORBIDDEN', 'Not allowed to perform this action', 403));
|
||||
}
|
||||
return next();
|
||||
};
|
||||
}
|
||||
25
backend/query-api/src/middleware/error-handler.js
Normal file
25
backend/query-api/src/middleware/error-handler.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { toErrorEnvelope } from '../lib/errors.js';
|
||||
|
||||
export function notFoundHandler(req, res) {
|
||||
res.status(404).json({
|
||||
code: 'NOT_FOUND',
|
||||
message: `Route not found: ${req.method} ${req.path}`,
|
||||
details: {},
|
||||
requestId: req.requestId,
|
||||
});
|
||||
}
|
||||
|
||||
export function errorHandler(error, req, res, _next) {
|
||||
const envelope = toErrorEnvelope(error, req.requestId);
|
||||
if (req.log) {
|
||||
req.log.error(
|
||||
{
|
||||
errCode: envelope.body.code,
|
||||
status: envelope.status,
|
||||
details: envelope.body.details,
|
||||
},
|
||||
envelope.body.message
|
||||
);
|
||||
}
|
||||
res.status(envelope.status).json(envelope.body);
|
||||
}
|
||||
9
backend/query-api/src/middleware/request-context.js
Normal file
9
backend/query-api/src/middleware/request-context.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
export function requestContext(req, res, next) {
|
||||
const incoming = req.get('X-Request-Id');
|
||||
req.requestId = incoming || randomUUID();
|
||||
res.setHeader('X-Request-Id', req.requestId);
|
||||
res.locals.startedAt = Date.now();
|
||||
next();
|
||||
}
|
||||
45
backend/query-api/src/routes/health.js
Normal file
45
backend/query-api/src/routes/health.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Router } from 'express';
|
||||
import { checkDatabaseHealth, isDatabaseConfigured } from '../services/db.js';
|
||||
|
||||
export const healthRouter = Router();
|
||||
|
||||
function healthHandler(req, res) {
|
||||
res.status(200).json({
|
||||
ok: true,
|
||||
service: 'krow-query-api',
|
||||
version: process.env.SERVICE_VERSION || 'dev',
|
||||
requestId: req.requestId,
|
||||
});
|
||||
}
|
||||
|
||||
healthRouter.get('/health', healthHandler);
|
||||
healthRouter.get('/healthz', healthHandler);
|
||||
|
||||
healthRouter.get('/readyz', async (req, res) => {
|
||||
if (!isDatabaseConfigured()) {
|
||||
return res.status(503).json({
|
||||
ok: false,
|
||||
service: 'krow-query-api',
|
||||
status: 'DATABASE_NOT_CONFIGURED',
|
||||
requestId: req.requestId,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const ok = await checkDatabaseHealth();
|
||||
return res.status(ok ? 200 : 503).json({
|
||||
ok,
|
||||
service: 'krow-query-api',
|
||||
status: ok ? 'READY' : 'DATABASE_UNAVAILABLE',
|
||||
requestId: req.requestId,
|
||||
});
|
||||
} catch (error) {
|
||||
return res.status(503).json({
|
||||
ok: false,
|
||||
service: 'krow-query-api',
|
||||
status: 'DATABASE_UNAVAILABLE',
|
||||
details: { message: error.message },
|
||||
requestId: req.requestId,
|
||||
});
|
||||
}
|
||||
});
|
||||
662
backend/query-api/src/routes/mobile.js
Normal file
662
backend/query-api/src/routes/mobile.js
Normal file
@@ -0,0 +1,662 @@
|
||||
import { Router } from 'express';
|
||||
import { requireAuth, requirePolicy } from '../middleware/auth.js';
|
||||
import {
|
||||
getCoverageReport,
|
||||
getClientDashboard,
|
||||
getClientSession,
|
||||
getCoverageStats,
|
||||
getCurrentAttendanceStatus,
|
||||
getCurrentBill,
|
||||
getDailyOpsReport,
|
||||
getPaymentChart,
|
||||
getPaymentsSummary,
|
||||
getPersonalInfo,
|
||||
getPerformanceReport,
|
||||
getProfileSectionsStatus,
|
||||
getPrivacySettings,
|
||||
getForecastReport,
|
||||
getNoShowReport,
|
||||
getOrderReorderPreview,
|
||||
listGeofenceIncidents,
|
||||
getReportSummary,
|
||||
getSavings,
|
||||
getStaffDashboard,
|
||||
getStaffProfileCompletion,
|
||||
getStaffSession,
|
||||
getStaffShiftDetail,
|
||||
listAttireChecklist,
|
||||
listAssignedShifts,
|
||||
listBusinessAccounts,
|
||||
listBusinessTeamMembers,
|
||||
listCancelledShifts,
|
||||
listCertificates,
|
||||
listCostCenters,
|
||||
listCoreTeam,
|
||||
listCoverageByDate,
|
||||
listCompletedShifts,
|
||||
listEmergencyContacts,
|
||||
listFaqCategories,
|
||||
listHubManagers,
|
||||
listHubs,
|
||||
listIndustries,
|
||||
listInvoiceHistory,
|
||||
listOpenShifts,
|
||||
listTaxForms,
|
||||
listTimeCardEntries,
|
||||
listOrderItemsByDateRange,
|
||||
listPaymentsHistory,
|
||||
listPendingAssignments,
|
||||
listPendingInvoices,
|
||||
listProfileDocuments,
|
||||
listRecentReorders,
|
||||
listSkills,
|
||||
listStaffAvailability,
|
||||
listStaffBankAccounts,
|
||||
listStaffBenefits,
|
||||
listTodayShifts,
|
||||
listVendorRoles,
|
||||
listVendors,
|
||||
searchFaqs,
|
||||
getSpendBreakdown,
|
||||
getSpendReport,
|
||||
} from '../services/mobile-query-service.js';
|
||||
|
||||
const defaultQueryService = {
|
||||
getClientDashboard,
|
||||
getClientSession,
|
||||
getCoverageReport,
|
||||
getCoverageStats,
|
||||
getCurrentAttendanceStatus,
|
||||
getCurrentBill,
|
||||
getDailyOpsReport,
|
||||
getPaymentChart,
|
||||
getPaymentsSummary,
|
||||
getPersonalInfo,
|
||||
getPerformanceReport,
|
||||
getProfileSectionsStatus,
|
||||
getPrivacySettings,
|
||||
getForecastReport,
|
||||
getNoShowReport,
|
||||
getOrderReorderPreview,
|
||||
listGeofenceIncidents,
|
||||
getReportSummary,
|
||||
getSavings,
|
||||
getSpendBreakdown,
|
||||
getSpendReport,
|
||||
getStaffDashboard,
|
||||
getStaffProfileCompletion,
|
||||
getStaffSession,
|
||||
getStaffShiftDetail,
|
||||
listAttireChecklist,
|
||||
listAssignedShifts,
|
||||
listBusinessAccounts,
|
||||
listBusinessTeamMembers,
|
||||
listCancelledShifts,
|
||||
listCertificates,
|
||||
listCostCenters,
|
||||
listCoreTeam,
|
||||
listCoverageByDate,
|
||||
listCompletedShifts,
|
||||
listEmergencyContacts,
|
||||
listFaqCategories,
|
||||
listHubManagers,
|
||||
listHubs,
|
||||
listIndustries,
|
||||
listInvoiceHistory,
|
||||
listOpenShifts,
|
||||
listTaxForms,
|
||||
listTimeCardEntries,
|
||||
listOrderItemsByDateRange,
|
||||
listPaymentsHistory,
|
||||
listPendingAssignments,
|
||||
listPendingInvoices,
|
||||
listProfileDocuments,
|
||||
listRecentReorders,
|
||||
listSkills,
|
||||
listStaffAvailability,
|
||||
listStaffBankAccounts,
|
||||
listStaffBenefits,
|
||||
listTodayShifts,
|
||||
listVendorRoles,
|
||||
listVendors,
|
||||
searchFaqs,
|
||||
};
|
||||
|
||||
function requireQueryParam(name, value) {
|
||||
if (!value) {
|
||||
const error = new Error(`${name} is required`);
|
||||
error.code = 'VALIDATION_ERROR';
|
||||
error.status = 400;
|
||||
error.details = { field: name };
|
||||
throw error;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function createMobileQueryRouter(queryService = defaultQueryService) {
|
||||
const router = Router();
|
||||
|
||||
router.get('/client/session', requireAuth, requirePolicy('client.session.read', 'session'), async (req, res, next) => {
|
||||
try {
|
||||
const data = await queryService.getClientSession(req.actor.uid);
|
||||
return res.status(200).json({ ...data, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/client/dashboard', requireAuth, requirePolicy('client.dashboard.read', 'dashboard'), async (req, res, next) => {
|
||||
try {
|
||||
const data = await queryService.getClientDashboard(req.actor.uid);
|
||||
return res.status(200).json({ ...data, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/client/reorders', requireAuth, requirePolicy('orders.reorder.read', 'order'), async (req, res, next) => {
|
||||
try {
|
||||
const items = await queryService.listRecentReorders(req.actor.uid, req.query.limit);
|
||||
return res.status(200).json({ items, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/client/billing/accounts', requireAuth, requirePolicy('billing.accounts.read', 'billing'), async (req, res, next) => {
|
||||
try {
|
||||
const items = await queryService.listBusinessAccounts(req.actor.uid);
|
||||
return res.status(200).json({ items, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/client/billing/invoices/pending', requireAuth, requirePolicy('billing.invoices.read', 'billing'), async (req, res, next) => {
|
||||
try {
|
||||
const items = await queryService.listPendingInvoices(req.actor.uid);
|
||||
return res.status(200).json({ items, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/client/billing/invoices/history', requireAuth, requirePolicy('billing.invoices.read', 'billing'), async (req, res, next) => {
|
||||
try {
|
||||
const items = await queryService.listInvoiceHistory(req.actor.uid);
|
||||
return res.status(200).json({ items, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/client/billing/current-bill', requireAuth, requirePolicy('billing.summary.read', 'billing'), async (req, res, next) => {
|
||||
try {
|
||||
const data = await queryService.getCurrentBill(req.actor.uid);
|
||||
return res.status(200).json({ ...data, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/client/billing/savings', requireAuth, requirePolicy('billing.summary.read', 'billing'), async (req, res, next) => {
|
||||
try {
|
||||
const data = await queryService.getSavings(req.actor.uid);
|
||||
return res.status(200).json({ ...data, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/client/billing/spend-breakdown', requireAuth, requirePolicy('billing.summary.read', 'billing'), async (req, res, next) => {
|
||||
try {
|
||||
const items = await queryService.getSpendBreakdown(req.actor.uid, req.query);
|
||||
return res.status(200).json({ items, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/client/coverage', requireAuth, requirePolicy('coverage.read', 'coverage'), async (req, res, next) => {
|
||||
try {
|
||||
const items = await queryService.listCoverageByDate(req.actor.uid, { date: requireQueryParam('date', req.query.date) });
|
||||
return res.status(200).json({ items, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/client/coverage/stats', requireAuth, requirePolicy('coverage.read', 'coverage'), async (req, res, next) => {
|
||||
try {
|
||||
const data = await queryService.getCoverageStats(req.actor.uid, { date: requireQueryParam('date', req.query.date) });
|
||||
return res.status(200).json({ ...data, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/client/coverage/core-team', requireAuth, requirePolicy('coverage.read', 'coverage'), async (req, res, next) => {
|
||||
try {
|
||||
const items = await queryService.listCoreTeam(req.actor.uid);
|
||||
return res.status(200).json({ items, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/client/coverage/incidents', requireAuth, requirePolicy('coverage.read', 'coverage'), async (req, res, next) => {
|
||||
try {
|
||||
const items = await queryService.listGeofenceIncidents(req.actor.uid, req.query);
|
||||
return res.status(200).json({ items, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/client/hubs', requireAuth, requirePolicy('hubs.read', 'hub'), async (req, res, next) => {
|
||||
try {
|
||||
const items = await queryService.listHubs(req.actor.uid);
|
||||
return res.status(200).json({ items, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/client/cost-centers', requireAuth, requirePolicy('hubs.read', 'hub'), async (req, res, next) => {
|
||||
try {
|
||||
const items = await queryService.listCostCenters(req.actor.uid);
|
||||
return res.status(200).json({ items, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/client/vendors', requireAuth, requirePolicy('vendors.read', 'vendor'), async (req, res, next) => {
|
||||
try {
|
||||
const items = await queryService.listVendors(req.actor.uid);
|
||||
return res.status(200).json({ items, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/client/vendors/:vendorId/roles', requireAuth, requirePolicy('vendors.read', 'vendor'), async (req, res, next) => {
|
||||
try {
|
||||
const items = await queryService.listVendorRoles(req.actor.uid, req.params.vendorId);
|
||||
return res.status(200).json({ items, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/client/hubs/:hubId/managers', requireAuth, requirePolicy('hubs.read', 'hub'), async (req, res, next) => {
|
||||
try {
|
||||
const items = await queryService.listHubManagers(req.actor.uid, req.params.hubId);
|
||||
return res.status(200).json({ items, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/client/team-members', requireAuth, requirePolicy('hubs.read', 'hub'), async (req, res, next) => {
|
||||
try {
|
||||
const items = await queryService.listBusinessTeamMembers(req.actor.uid);
|
||||
return res.status(200).json({ items, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/client/orders/view', requireAuth, requirePolicy('orders.read', 'order'), async (req, res, next) => {
|
||||
try {
|
||||
const items = await queryService.listOrderItemsByDateRange(req.actor.uid, req.query);
|
||||
return res.status(200).json({ items, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/client/orders/:orderId/reorder-preview', requireAuth, requirePolicy('orders.read', 'order'), async (req, res, next) => {
|
||||
try {
|
||||
const data = await queryService.getOrderReorderPreview(req.actor.uid, req.params.orderId);
|
||||
return res.status(200).json({ ...data, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/client/reports/summary', requireAuth, requirePolicy('reports.read', 'report'), async (req, res, next) => {
|
||||
try {
|
||||
const data = await queryService.getReportSummary(req.actor.uid, req.query);
|
||||
return res.status(200).json({ ...data, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/client/reports/daily-ops', requireAuth, requirePolicy('reports.read', 'report'), async (req, res, next) => {
|
||||
try {
|
||||
const data = await queryService.getDailyOpsReport(req.actor.uid, { date: requireQueryParam('date', req.query.date) });
|
||||
return res.status(200).json({ ...data, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/client/reports/spend', requireAuth, requirePolicy('reports.read', 'report'), async (req, res, next) => {
|
||||
try {
|
||||
const data = await queryService.getSpendReport(req.actor.uid, req.query);
|
||||
return res.status(200).json({ ...data, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/client/reports/coverage', requireAuth, requirePolicy('reports.read', 'report'), async (req, res, next) => {
|
||||
try {
|
||||
const data = await queryService.getCoverageReport(req.actor.uid, req.query);
|
||||
return res.status(200).json({ ...data, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/client/reports/forecast', requireAuth, requirePolicy('reports.read', 'report'), async (req, res, next) => {
|
||||
try {
|
||||
const data = await queryService.getForecastReport(req.actor.uid, req.query);
|
||||
return res.status(200).json({ ...data, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/client/reports/performance', requireAuth, requirePolicy('reports.read', 'report'), async (req, res, next) => {
|
||||
try {
|
||||
const data = await queryService.getPerformanceReport(req.actor.uid, req.query);
|
||||
return res.status(200).json({ ...data, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/client/reports/no-show', requireAuth, requirePolicy('reports.read', 'report'), async (req, res, next) => {
|
||||
try {
|
||||
const data = await queryService.getNoShowReport(req.actor.uid, req.query);
|
||||
return res.status(200).json({ ...data, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/staff/session', requireAuth, requirePolicy('staff.session.read', 'session'), async (req, res, next) => {
|
||||
try {
|
||||
const data = await queryService.getStaffSession(req.actor.uid);
|
||||
return res.status(200).json({ ...data, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/staff/dashboard', requireAuth, requirePolicy('staff.dashboard.read', 'dashboard'), async (req, res, next) => {
|
||||
try {
|
||||
const data = await queryService.getStaffDashboard(req.actor.uid);
|
||||
return res.status(200).json({ ...data, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/staff/profile-completion', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => {
|
||||
try {
|
||||
const data = await queryService.getStaffProfileCompletion(req.actor.uid);
|
||||
return res.status(200).json({ ...data, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/staff/availability', requireAuth, requirePolicy('staff.availability.read', 'staff'), async (req, res, next) => {
|
||||
try {
|
||||
const items = await queryService.listStaffAvailability(req.actor.uid, req.query);
|
||||
return res.status(200).json({ items, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/staff/clock-in/shifts/today', requireAuth, requirePolicy('attendance.read', 'attendance'), async (req, res, next) => {
|
||||
try {
|
||||
const items = await queryService.listTodayShifts(req.actor.uid);
|
||||
return res.status(200).json({ items, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/staff/clock-in/status', requireAuth, requirePolicy('attendance.read', 'attendance'), async (req, res, next) => {
|
||||
try {
|
||||
const data = await queryService.getCurrentAttendanceStatus(req.actor.uid);
|
||||
return res.status(200).json({ ...data, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/staff/payments/summary', requireAuth, requirePolicy('payments.read', 'payment'), async (req, res, next) => {
|
||||
try {
|
||||
const data = await queryService.getPaymentsSummary(req.actor.uid, req.query);
|
||||
return res.status(200).json({ ...data, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/staff/payments/history', requireAuth, requirePolicy('payments.read', 'payment'), async (req, res, next) => {
|
||||
try {
|
||||
const items = await queryService.listPaymentsHistory(req.actor.uid, req.query);
|
||||
return res.status(200).json({ items, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/staff/payments/chart', requireAuth, requirePolicy('payments.read', 'payment'), async (req, res, next) => {
|
||||
try {
|
||||
const items = await queryService.getPaymentChart(req.actor.uid, req.query);
|
||||
return res.status(200).json({ items, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/staff/shifts/assigned', requireAuth, requirePolicy('shifts.read', 'shift'), async (req, res, next) => {
|
||||
try {
|
||||
const items = await queryService.listAssignedShifts(req.actor.uid, req.query);
|
||||
return res.status(200).json({ items, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/staff/shifts/open', requireAuth, requirePolicy('shifts.read', 'shift'), async (req, res, next) => {
|
||||
try {
|
||||
const items = await queryService.listOpenShifts(req.actor.uid, req.query);
|
||||
return res.status(200).json({ items, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/staff/shifts/pending', requireAuth, requirePolicy('shifts.read', 'shift'), async (req, res, next) => {
|
||||
try {
|
||||
const items = await queryService.listPendingAssignments(req.actor.uid);
|
||||
return res.status(200).json({ items, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/staff/shifts/cancelled', requireAuth, requirePolicy('shifts.read', 'shift'), async (req, res, next) => {
|
||||
try {
|
||||
const items = await queryService.listCancelledShifts(req.actor.uid);
|
||||
return res.status(200).json({ items, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/staff/shifts/completed', requireAuth, requirePolicy('shifts.read', 'shift'), async (req, res, next) => {
|
||||
try {
|
||||
const items = await queryService.listCompletedShifts(req.actor.uid);
|
||||
return res.status(200).json({ items, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/staff/shifts/:shiftId', requireAuth, requirePolicy('shifts.read', 'shift'), async (req, res, next) => {
|
||||
try {
|
||||
const data = await queryService.getStaffShiftDetail(req.actor.uid, req.params.shiftId);
|
||||
return res.status(200).json({ ...data, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/staff/profile/sections', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => {
|
||||
try {
|
||||
const data = await queryService.getProfileSectionsStatus(req.actor.uid);
|
||||
return res.status(200).json({ ...data, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/staff/profile/personal-info', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => {
|
||||
try {
|
||||
const data = await queryService.getPersonalInfo(req.actor.uid);
|
||||
return res.status(200).json({ ...data, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/staff/profile/industries', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => {
|
||||
try {
|
||||
const items = await queryService.listIndustries(req.actor.uid);
|
||||
return res.status(200).json({ items, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/staff/profile/skills', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => {
|
||||
try {
|
||||
const items = await queryService.listSkills(req.actor.uid);
|
||||
return res.status(200).json({ items, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/staff/profile/documents', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => {
|
||||
try {
|
||||
const items = await queryService.listProfileDocuments(req.actor.uid);
|
||||
return res.status(200).json({ items, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/staff/profile/attire', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => {
|
||||
try {
|
||||
const items = await queryService.listAttireChecklist(req.actor.uid);
|
||||
return res.status(200).json({ items, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/staff/profile/tax-forms', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => {
|
||||
try {
|
||||
const items = await queryService.listTaxForms(req.actor.uid);
|
||||
return res.status(200).json({ items, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/staff/profile/emergency-contacts', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => {
|
||||
try {
|
||||
const items = await queryService.listEmergencyContacts(req.actor.uid);
|
||||
return res.status(200).json({ items, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/staff/profile/certificates', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => {
|
||||
try {
|
||||
const items = await queryService.listCertificates(req.actor.uid);
|
||||
return res.status(200).json({ items, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/staff/profile/bank-accounts', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => {
|
||||
try {
|
||||
const items = await queryService.listStaffBankAccounts(req.actor.uid);
|
||||
return res.status(200).json({ items, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/staff/profile/benefits', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => {
|
||||
try {
|
||||
const items = await queryService.listStaffBenefits(req.actor.uid);
|
||||
return res.status(200).json({ items, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/staff/profile/time-card', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => {
|
||||
try {
|
||||
const items = await queryService.listTimeCardEntries(req.actor.uid, req.query);
|
||||
return res.status(200).json({ items, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/staff/profile/privacy', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => {
|
||||
try {
|
||||
const data = await queryService.getPrivacySettings(req.actor.uid);
|
||||
return res.status(200).json({ ...data, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/staff/faqs', async (req, res, next) => {
|
||||
try {
|
||||
const items = await queryService.listFaqCategories();
|
||||
return res.status(200).json({ items, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/staff/faqs/search', async (req, res, next) => {
|
||||
try {
|
||||
const items = await queryService.searchFaqs(req.query.q || '');
|
||||
return res.status(200).json({ items, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
138
backend/query-api/src/routes/query.js
Normal file
138
backend/query-api/src/routes/query.js
Normal file
@@ -0,0 +1,138 @@
|
||||
import { Router } from 'express';
|
||||
import { AppError } from '../lib/errors.js';
|
||||
import { requireAuth, requirePolicy } from '../middleware/auth.js';
|
||||
import {
|
||||
getAssignmentAttendance,
|
||||
getOrderDetail,
|
||||
getStaffReviewSummary,
|
||||
listFavoriteStaff,
|
||||
listOrders,
|
||||
} from '../services/query-service.js';
|
||||
|
||||
const defaultQueryService = {
|
||||
getAssignmentAttendance,
|
||||
getOrderDetail,
|
||||
getStaffReviewSummary,
|
||||
listFavoriteStaff,
|
||||
listOrders,
|
||||
};
|
||||
|
||||
function requireUuid(value, field) {
|
||||
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)) {
|
||||
throw new AppError('VALIDATION_ERROR', `${field} must be a UUID`, 400, { field });
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function createQueryRouter(queryService = defaultQueryService) {
|
||||
const router = Router();
|
||||
|
||||
router.get(
|
||||
'/tenants/:tenantId/orders',
|
||||
requireAuth,
|
||||
requirePolicy('orders.read', 'order'),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const tenantId = requireUuid(req.params.tenantId, 'tenantId');
|
||||
const orders = await queryService.listOrders({
|
||||
tenantId,
|
||||
businessId: req.query.businessId,
|
||||
status: req.query.status,
|
||||
limit: req.query.limit,
|
||||
offset: req.query.offset,
|
||||
});
|
||||
return res.status(200).json({
|
||||
items: orders,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/tenants/:tenantId/orders/:orderId',
|
||||
requireAuth,
|
||||
requirePolicy('orders.read', 'order'),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const order = await queryService.getOrderDetail({
|
||||
tenantId: requireUuid(req.params.tenantId, 'tenantId'),
|
||||
orderId: requireUuid(req.params.orderId, 'orderId'),
|
||||
});
|
||||
return res.status(200).json({
|
||||
...order,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/tenants/:tenantId/businesses/:businessId/favorite-staff',
|
||||
requireAuth,
|
||||
requirePolicy('business.favorite-staff.read', 'staff'),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const items = await queryService.listFavoriteStaff({
|
||||
tenantId: requireUuid(req.params.tenantId, 'tenantId'),
|
||||
businessId: requireUuid(req.params.businessId, 'businessId'),
|
||||
limit: req.query.limit,
|
||||
offset: req.query.offset,
|
||||
});
|
||||
return res.status(200).json({
|
||||
items,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/tenants/:tenantId/staff/:staffId/review-summary',
|
||||
requireAuth,
|
||||
requirePolicy('staff.reviews.read', 'staff'),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const summary = await queryService.getStaffReviewSummary({
|
||||
tenantId: requireUuid(req.params.tenantId, 'tenantId'),
|
||||
staffId: requireUuid(req.params.staffId, 'staffId'),
|
||||
limit: req.query.limit,
|
||||
});
|
||||
return res.status(200).json({
|
||||
...summary,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/tenants/:tenantId/assignments/:assignmentId/attendance',
|
||||
requireAuth,
|
||||
requirePolicy('attendance.read', 'attendance'),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const attendance = await queryService.getAssignmentAttendance({
|
||||
tenantId: requireUuid(req.params.tenantId, 'tenantId'),
|
||||
assignmentId: requireUuid(req.params.assignmentId, 'assignmentId'),
|
||||
});
|
||||
return res.status(200).json({
|
||||
...attendance,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
9
backend/query-api/src/server.js
Normal file
9
backend/query-api/src/server.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { createApp } from './app.js';
|
||||
|
||||
const port = Number(process.env.PORT || 8080);
|
||||
const app = createApp();
|
||||
|
||||
app.listen(port, () => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`krow-query-api listening on port ${port}`);
|
||||
});
|
||||
111
backend/query-api/src/services/actor-context.js
Normal file
111
backend/query-api/src/services/actor-context.js
Normal file
@@ -0,0 +1,111 @@
|
||||
import { AppError } from '../lib/errors.js';
|
||||
import { query } from './db.js';
|
||||
|
||||
export async function loadActorContext(uid) {
|
||||
const [userResult, tenantResult, businessResult, vendorResult, staffResult] = await Promise.all([
|
||||
query(
|
||||
`
|
||||
SELECT id AS "userId", email, display_name AS "displayName", phone, status
|
||||
FROM users
|
||||
WHERE id = $1
|
||||
`,
|
||||
[uid]
|
||||
),
|
||||
query(
|
||||
`
|
||||
SELECT tm.id AS "membershipId",
|
||||
tm.tenant_id AS "tenantId",
|
||||
tm.base_role AS role,
|
||||
t.name AS "tenantName",
|
||||
t.slug AS "tenantSlug"
|
||||
FROM tenant_memberships tm
|
||||
JOIN tenants t ON t.id = tm.tenant_id
|
||||
WHERE tm.user_id = $1
|
||||
AND tm.membership_status = 'ACTIVE'
|
||||
ORDER BY tm.created_at ASC
|
||||
LIMIT 1
|
||||
`,
|
||||
[uid]
|
||||
),
|
||||
query(
|
||||
`
|
||||
SELECT bm.id AS "membershipId",
|
||||
bm.business_id AS "businessId",
|
||||
bm.business_role AS role,
|
||||
b.business_name AS "businessName",
|
||||
b.slug AS "businessSlug",
|
||||
bm.tenant_id AS "tenantId"
|
||||
FROM business_memberships bm
|
||||
JOIN businesses b ON b.id = bm.business_id
|
||||
WHERE bm.user_id = $1
|
||||
AND bm.membership_status = 'ACTIVE'
|
||||
ORDER BY bm.created_at ASC
|
||||
LIMIT 1
|
||||
`,
|
||||
[uid]
|
||||
),
|
||||
query(
|
||||
`
|
||||
SELECT vm.id AS "membershipId",
|
||||
vm.vendor_id AS "vendorId",
|
||||
vm.vendor_role AS role,
|
||||
v.company_name AS "vendorName",
|
||||
v.slug AS "vendorSlug",
|
||||
vm.tenant_id AS "tenantId"
|
||||
FROM vendor_memberships vm
|
||||
JOIN vendors v ON v.id = vm.vendor_id
|
||||
WHERE vm.user_id = $1
|
||||
AND vm.membership_status = 'ACTIVE'
|
||||
ORDER BY vm.created_at ASC
|
||||
LIMIT 1
|
||||
`,
|
||||
[uid]
|
||||
),
|
||||
query(
|
||||
`
|
||||
SELECT s.id AS "staffId",
|
||||
s.tenant_id AS "tenantId",
|
||||
s.full_name AS "fullName",
|
||||
s.email,
|
||||
s.phone,
|
||||
s.primary_role AS "primaryRole",
|
||||
s.onboarding_status AS "onboardingStatus",
|
||||
s.status,
|
||||
s.metadata,
|
||||
w.id AS "workforceId",
|
||||
w.vendor_id AS "vendorId",
|
||||
w.workforce_number AS "workforceNumber"
|
||||
FROM staffs s
|
||||
LEFT JOIN workforce w ON w.staff_id = s.id
|
||||
WHERE s.user_id = $1
|
||||
ORDER BY s.created_at ASC
|
||||
LIMIT 1
|
||||
`,
|
||||
[uid]
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
user: userResult.rows[0] || null,
|
||||
tenant: tenantResult.rows[0] || null,
|
||||
business: businessResult.rows[0] || null,
|
||||
vendor: vendorResult.rows[0] || null,
|
||||
staff: staffResult.rows[0] || null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function requireClientContext(uid) {
|
||||
const context = await loadActorContext(uid);
|
||||
if (!context.user || !context.tenant || !context.business) {
|
||||
throw new AppError('FORBIDDEN', 'Client business context is required for this route', 403, { uid });
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export async function requireStaffContext(uid) {
|
||||
const context = await loadActorContext(uid);
|
||||
if (!context.user || !context.tenant || !context.staff) {
|
||||
throw new AppError('FORBIDDEN', 'Staff context is required for this route', 403, { uid });
|
||||
}
|
||||
return context;
|
||||
}
|
||||
84
backend/query-api/src/services/db.js
Normal file
84
backend/query-api/src/services/db.js
Normal file
@@ -0,0 +1,84 @@
|
||||
import pg from 'pg';
|
||||
|
||||
const { Pool, types } = pg;
|
||||
|
||||
function parseNumericDatabaseValue(value) {
|
||||
if (value == null) return value;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : value;
|
||||
}
|
||||
|
||||
// Mobile/frontend routes expect numeric JSON values for database aggregates.
|
||||
types.setTypeParser(types.builtins.INT8, parseNumericDatabaseValue);
|
||||
types.setTypeParser(types.builtins.NUMERIC, parseNumericDatabaseValue);
|
||||
|
||||
let pool;
|
||||
|
||||
function parseIntOrDefault(value, fallback) {
|
||||
const parsed = Number.parseInt(`${value || fallback}`, 10);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function resolveDatabasePoolConfig() {
|
||||
if (process.env.DATABASE_URL) {
|
||||
return {
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
max: parseIntOrDefault(process.env.DB_POOL_MAX, 10),
|
||||
idleTimeoutMillis: parseIntOrDefault(process.env.DB_IDLE_TIMEOUT_MS, 30000),
|
||||
};
|
||||
}
|
||||
|
||||
const user = process.env.DB_USER;
|
||||
const password = process.env.DB_PASSWORD;
|
||||
const database = process.env.DB_NAME;
|
||||
const host = process.env.DB_HOST || (
|
||||
process.env.INSTANCE_CONNECTION_NAME
|
||||
? `/cloudsql/${process.env.INSTANCE_CONNECTION_NAME}`
|
||||
: ''
|
||||
);
|
||||
|
||||
if (!user || password == null || !database || !host) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
host,
|
||||
port: parseIntOrDefault(process.env.DB_PORT, 5432),
|
||||
user,
|
||||
password,
|
||||
database,
|
||||
max: parseIntOrDefault(process.env.DB_POOL_MAX, 10),
|
||||
idleTimeoutMillis: parseIntOrDefault(process.env.DB_IDLE_TIMEOUT_MS, 30000),
|
||||
};
|
||||
}
|
||||
|
||||
export function isDatabaseConfigured() {
|
||||
return Boolean(resolveDatabasePoolConfig());
|
||||
}
|
||||
|
||||
function getPool() {
|
||||
if (!pool) {
|
||||
const resolved = resolveDatabasePoolConfig();
|
||||
if (!resolved) {
|
||||
throw new Error('Database connection settings are required');
|
||||
}
|
||||
pool = new Pool(resolved);
|
||||
}
|
||||
return pool;
|
||||
}
|
||||
|
||||
export async function query(text, params = []) {
|
||||
return getPool().query(text, params);
|
||||
}
|
||||
|
||||
export async function checkDatabaseHealth() {
|
||||
const result = await query('SELECT 1 AS ok');
|
||||
return result.rows[0]?.ok === 1;
|
||||
}
|
||||
|
||||
export async function closePool() {
|
||||
if (pool) {
|
||||
await pool.end();
|
||||
pool = null;
|
||||
}
|
||||
}
|
||||
13
backend/query-api/src/services/firebase-auth.js
Normal file
13
backend/query-api/src/services/firebase-auth.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import { applicationDefault, getApps, initializeApp } from 'firebase-admin/app';
|
||||
import { getAuth } from 'firebase-admin/auth';
|
||||
|
||||
function ensureAdminApp() {
|
||||
if (getApps().length === 0) {
|
||||
initializeApp({ credential: applicationDefault() });
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyFirebaseToken(token) {
|
||||
ensureAdminApp();
|
||||
return getAuth().verifyIdToken(token);
|
||||
}
|
||||
1746
backend/query-api/src/services/mobile-query-service.js
Normal file
1746
backend/query-api/src/services/mobile-query-service.js
Normal file
File diff suppressed because it is too large
Load Diff
5
backend/query-api/src/services/policy.js
Normal file
5
backend/query-api/src/services/policy.js
Normal file
@@ -0,0 +1,5 @@
|
||||
export function can(action, resource, actor) {
|
||||
void action;
|
||||
void resource;
|
||||
return Boolean(actor?.uid);
|
||||
}
|
||||
285
backend/query-api/src/services/query-service.js
Normal file
285
backend/query-api/src/services/query-service.js
Normal file
@@ -0,0 +1,285 @@
|
||||
import { AppError } from '../lib/errors.js';
|
||||
import { query } from './db.js';
|
||||
|
||||
function parseLimit(value, fallback = 20, max = 100) {
|
||||
const parsed = Number.parseInt(`${value || fallback}`, 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
|
||||
return Math.min(parsed, max);
|
||||
}
|
||||
|
||||
function parseOffset(value) {
|
||||
const parsed = Number.parseInt(`${value || 0}`, 10);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) return 0;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export async function listOrders({ tenantId, businessId, status, limit, offset }) {
|
||||
const result = await query(
|
||||
`
|
||||
SELECT
|
||||
o.id,
|
||||
o.order_number AS "orderNumber",
|
||||
o.title,
|
||||
o.status,
|
||||
o.service_type AS "serviceType",
|
||||
o.starts_at AS "startsAt",
|
||||
o.ends_at AS "endsAt",
|
||||
o.location_name AS "locationName",
|
||||
o.location_address AS "locationAddress",
|
||||
o.created_at AS "createdAt",
|
||||
b.id AS "businessId",
|
||||
b.business_name AS "businessName",
|
||||
v.id AS "vendorId",
|
||||
v.company_name AS "vendorName",
|
||||
COALESCE(COUNT(s.id), 0)::INTEGER AS "shiftCount",
|
||||
COALESCE(SUM(s.required_workers), 0)::INTEGER AS "requiredWorkers",
|
||||
COALESCE(SUM(s.assigned_workers), 0)::INTEGER AS "assignedWorkers"
|
||||
FROM orders o
|
||||
JOIN businesses b ON b.id = o.business_id
|
||||
LEFT JOIN vendors v ON v.id = o.vendor_id
|
||||
LEFT JOIN shifts s ON s.order_id = o.id
|
||||
WHERE o.tenant_id = $1
|
||||
AND ($2::uuid IS NULL OR o.business_id = $2::uuid)
|
||||
AND ($3::text IS NULL OR o.status = $3::text)
|
||||
GROUP BY o.id, b.id, v.id
|
||||
ORDER BY o.created_at DESC
|
||||
LIMIT $4 OFFSET $5
|
||||
`,
|
||||
[
|
||||
tenantId,
|
||||
businessId || null,
|
||||
status || null,
|
||||
parseLimit(limit),
|
||||
parseOffset(offset),
|
||||
]
|
||||
);
|
||||
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
export async function getOrderDetail({ tenantId, orderId }) {
|
||||
const orderResult = await query(
|
||||
`
|
||||
SELECT
|
||||
o.id,
|
||||
o.order_number AS "orderNumber",
|
||||
o.title,
|
||||
o.description,
|
||||
o.status,
|
||||
o.service_type AS "serviceType",
|
||||
o.starts_at AS "startsAt",
|
||||
o.ends_at AS "endsAt",
|
||||
o.location_name AS "locationName",
|
||||
o.location_address AS "locationAddress",
|
||||
o.latitude,
|
||||
o.longitude,
|
||||
o.notes,
|
||||
o.created_at AS "createdAt",
|
||||
b.id AS "businessId",
|
||||
b.business_name AS "businessName",
|
||||
v.id AS "vendorId",
|
||||
v.company_name AS "vendorName"
|
||||
FROM orders o
|
||||
JOIN businesses b ON b.id = o.business_id
|
||||
LEFT JOIN vendors v ON v.id = o.vendor_id
|
||||
WHERE o.tenant_id = $1
|
||||
AND o.id = $2
|
||||
`,
|
||||
[tenantId, orderId]
|
||||
);
|
||||
|
||||
if (orderResult.rowCount === 0) {
|
||||
throw new AppError('NOT_FOUND', 'Order not found', 404, { tenantId, orderId });
|
||||
}
|
||||
|
||||
const shiftsResult = await query(
|
||||
`
|
||||
SELECT
|
||||
s.id,
|
||||
s.shift_code AS "shiftCode",
|
||||
s.title,
|
||||
s.status,
|
||||
s.starts_at AS "startsAt",
|
||||
s.ends_at AS "endsAt",
|
||||
s.timezone,
|
||||
s.location_name AS "locationName",
|
||||
s.location_address AS "locationAddress",
|
||||
s.required_workers AS "requiredWorkers",
|
||||
s.assigned_workers AS "assignedWorkers",
|
||||
cp.id AS "clockPointId",
|
||||
cp.label AS "clockPointLabel"
|
||||
FROM shifts s
|
||||
LEFT JOIN clock_points cp ON cp.id = s.clock_point_id
|
||||
WHERE s.tenant_id = $1
|
||||
AND s.order_id = $2
|
||||
ORDER BY s.starts_at ASC
|
||||
`,
|
||||
[tenantId, orderId]
|
||||
);
|
||||
|
||||
const shiftIds = shiftsResult.rows.map((row) => row.id);
|
||||
let rolesByShiftId = new Map();
|
||||
|
||||
if (shiftIds.length > 0) {
|
||||
const rolesResult = await query(
|
||||
`
|
||||
SELECT
|
||||
sr.id,
|
||||
sr.shift_id AS "shiftId",
|
||||
sr.role_code AS "roleCode",
|
||||
sr.role_name AS "roleName",
|
||||
sr.workers_needed AS "workersNeeded",
|
||||
sr.assigned_count AS "assignedCount",
|
||||
sr.pay_rate_cents AS "payRateCents",
|
||||
sr.bill_rate_cents AS "billRateCents"
|
||||
FROM shift_roles sr
|
||||
WHERE sr.shift_id = ANY($1::uuid[])
|
||||
ORDER BY sr.role_name ASC
|
||||
`,
|
||||
[shiftIds]
|
||||
);
|
||||
rolesByShiftId = rolesResult.rows.reduce((map, row) => {
|
||||
const list = map.get(row.shiftId) || [];
|
||||
list.push(row);
|
||||
map.set(row.shiftId, list);
|
||||
return map;
|
||||
}, new Map());
|
||||
}
|
||||
|
||||
return {
|
||||
...orderResult.rows[0],
|
||||
shifts: shiftsResult.rows.map((shift) => ({
|
||||
...shift,
|
||||
roles: rolesByShiftId.get(shift.id) || [],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listFavoriteStaff({ tenantId, businessId, limit, offset }) {
|
||||
const result = await query(
|
||||
`
|
||||
SELECT
|
||||
sf.id AS "favoriteId",
|
||||
sf.created_at AS "favoritedAt",
|
||||
s.id AS "staffId",
|
||||
s.full_name AS "fullName",
|
||||
s.primary_role AS "primaryRole",
|
||||
s.average_rating AS "averageRating",
|
||||
s.rating_count AS "ratingCount",
|
||||
s.status
|
||||
FROM staff_favorites sf
|
||||
JOIN staffs s ON s.id = sf.staff_id
|
||||
WHERE sf.tenant_id = $1
|
||||
AND sf.business_id = $2
|
||||
ORDER BY sf.created_at DESC
|
||||
LIMIT $3 OFFSET $4
|
||||
`,
|
||||
[tenantId, businessId, parseLimit(limit), parseOffset(offset)]
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
export async function getStaffReviewSummary({ tenantId, staffId, limit }) {
|
||||
const staffResult = await query(
|
||||
`
|
||||
SELECT
|
||||
id AS "staffId",
|
||||
full_name AS "fullName",
|
||||
average_rating AS "averageRating",
|
||||
rating_count AS "ratingCount",
|
||||
primary_role AS "primaryRole",
|
||||
status
|
||||
FROM staffs
|
||||
WHERE tenant_id = $1
|
||||
AND id = $2
|
||||
`,
|
||||
[tenantId, staffId]
|
||||
);
|
||||
|
||||
if (staffResult.rowCount === 0) {
|
||||
throw new AppError('NOT_FOUND', 'Staff not found', 404, { tenantId, staffId });
|
||||
}
|
||||
|
||||
const reviewsResult = await query(
|
||||
`
|
||||
SELECT
|
||||
sr.id AS "reviewId",
|
||||
sr.rating,
|
||||
sr.review_text AS "reviewText",
|
||||
sr.tags,
|
||||
sr.created_at AS "createdAt",
|
||||
b.id AS "businessId",
|
||||
b.business_name AS "businessName",
|
||||
sr.assignment_id AS "assignmentId"
|
||||
FROM staff_reviews sr
|
||||
JOIN businesses b ON b.id = sr.business_id
|
||||
WHERE sr.tenant_id = $1
|
||||
AND sr.staff_id = $2
|
||||
ORDER BY sr.created_at DESC
|
||||
LIMIT $3
|
||||
`,
|
||||
[tenantId, staffId, parseLimit(limit, 10, 50)]
|
||||
);
|
||||
|
||||
return {
|
||||
...staffResult.rows[0],
|
||||
reviews: reviewsResult.rows,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAssignmentAttendance({ tenantId, assignmentId }) {
|
||||
const assignmentResult = await query(
|
||||
`
|
||||
SELECT
|
||||
a.id AS "assignmentId",
|
||||
a.status,
|
||||
a.shift_id AS "shiftId",
|
||||
a.staff_id AS "staffId",
|
||||
s.title AS "shiftTitle",
|
||||
s.starts_at AS "shiftStartsAt",
|
||||
s.ends_at AS "shiftEndsAt",
|
||||
attendance_sessions.id AS "sessionId",
|
||||
attendance_sessions.status AS "sessionStatus",
|
||||
attendance_sessions.check_in_at AS "checkInAt",
|
||||
attendance_sessions.check_out_at AS "checkOutAt",
|
||||
attendance_sessions.worked_minutes AS "workedMinutes"
|
||||
FROM assignments a
|
||||
JOIN shifts s ON s.id = a.shift_id
|
||||
LEFT JOIN attendance_sessions ON attendance_sessions.assignment_id = a.id
|
||||
WHERE a.id = $1
|
||||
AND a.tenant_id = $2
|
||||
`,
|
||||
[assignmentId, tenantId]
|
||||
);
|
||||
|
||||
if (assignmentResult.rowCount === 0) {
|
||||
throw new AppError('NOT_FOUND', 'Assignment not found', 404, { tenantId, assignmentId });
|
||||
}
|
||||
|
||||
const eventsResult = await query(
|
||||
`
|
||||
SELECT
|
||||
id AS "attendanceEventId",
|
||||
event_type AS "eventType",
|
||||
source_type AS "sourceType",
|
||||
source_reference AS "sourceReference",
|
||||
nfc_tag_uid AS "nfcTagUid",
|
||||
latitude,
|
||||
longitude,
|
||||
distance_to_clock_point_meters AS "distanceToClockPointMeters",
|
||||
within_geofence AS "withinGeofence",
|
||||
validation_status AS "validationStatus",
|
||||
validation_reason AS "validationReason",
|
||||
captured_at AS "capturedAt"
|
||||
FROM attendance_events
|
||||
WHERE assignment_id = $1
|
||||
ORDER BY captured_at ASC
|
||||
`,
|
||||
[assignmentId]
|
||||
);
|
||||
|
||||
return {
|
||||
...assignmentResult.rows[0],
|
||||
events: eventsResult.rows,
|
||||
};
|
||||
}
|
||||
126
backend/query-api/test/app.test.js
Normal file
126
backend/query-api/test/app.test.js
Normal file
@@ -0,0 +1,126 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../src/app.js';
|
||||
|
||||
process.env.AUTH_BYPASS = 'true';
|
||||
|
||||
const tenantId = '11111111-1111-4111-8111-111111111111';
|
||||
const orderId = '22222222-2222-4222-8222-222222222222';
|
||||
const businessId = '33333333-3333-4333-8333-333333333333';
|
||||
const staffId = '44444444-4444-4444-8444-444444444444';
|
||||
const assignmentId = '55555555-5555-4555-8555-555555555555';
|
||||
|
||||
test('GET /healthz returns healthy response', async () => {
|
||||
const app = createApp();
|
||||
const res = await request(app).get('/healthz');
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.ok, true);
|
||||
assert.equal(res.body.service, 'krow-query-api');
|
||||
assert.equal(typeof res.body.requestId, 'string');
|
||||
assert.equal(typeof res.headers['x-request-id'], 'string');
|
||||
});
|
||||
|
||||
test('GET /readyz reports database not configured when no database env is present', async () => {
|
||||
delete process.env.DATABASE_URL;
|
||||
delete process.env.DB_HOST;
|
||||
delete process.env.DB_NAME;
|
||||
delete process.env.DB_USER;
|
||||
delete process.env.DB_PASSWORD;
|
||||
delete process.env.INSTANCE_CONNECTION_NAME;
|
||||
|
||||
const app = createApp();
|
||||
const res = await request(app).get('/readyz');
|
||||
|
||||
assert.equal(res.status, 503);
|
||||
assert.equal(res.body.status, 'DATABASE_NOT_CONFIGURED');
|
||||
});
|
||||
|
||||
test('GET unknown route returns not found envelope', async () => {
|
||||
const app = createApp();
|
||||
const res = await request(app).get('/query/unknown');
|
||||
|
||||
assert.equal(res.status, 404);
|
||||
assert.equal(res.body.code, 'NOT_FOUND');
|
||||
assert.equal(typeof res.body.requestId, 'string');
|
||||
});
|
||||
|
||||
test('GET /query/tenants/:tenantId/orders returns injected query result', async () => {
|
||||
const app = createApp({
|
||||
queryService: {
|
||||
listOrders: async (params) => {
|
||||
assert.equal(params.tenantId, tenantId);
|
||||
return [{
|
||||
id: orderId,
|
||||
orderNumber: 'ORD-1001',
|
||||
title: 'Cafe Event Staffing',
|
||||
status: 'OPEN',
|
||||
}];
|
||||
},
|
||||
getOrderDetail: async () => assert.fail('getOrderDetail should not be called'),
|
||||
listFavoriteStaff: async () => assert.fail('listFavoriteStaff should not be called'),
|
||||
getStaffReviewSummary: async () => assert.fail('getStaffReviewSummary should not be called'),
|
||||
getAssignmentAttendance: async () => assert.fail('getAssignmentAttendance should not be called'),
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/query/tenants/${tenantId}/orders`)
|
||||
.set('Authorization', 'Bearer test-token');
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.items.length, 1);
|
||||
assert.equal(res.body.items[0].id, orderId);
|
||||
});
|
||||
|
||||
test('GET /query/tenants/:tenantId/assignments/:assignmentId/attendance returns injected attendance', async () => {
|
||||
const app = createApp({
|
||||
queryService: {
|
||||
listOrders: async () => assert.fail('listOrders should not be called'),
|
||||
getOrderDetail: async () => assert.fail('getOrderDetail should not be called'),
|
||||
listFavoriteStaff: async () => assert.fail('listFavoriteStaff should not be called'),
|
||||
getStaffReviewSummary: async () => assert.fail('getStaffReviewSummary should not be called'),
|
||||
getAssignmentAttendance: async (params) => {
|
||||
assert.equal(params.tenantId, tenantId);
|
||||
assert.equal(params.assignmentId, assignmentId);
|
||||
return {
|
||||
assignmentId,
|
||||
sessionStatus: 'OPEN',
|
||||
events: [],
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/query/tenants/${tenantId}/assignments/${assignmentId}/attendance`)
|
||||
.set('Authorization', 'Bearer test-token');
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.assignmentId, assignmentId);
|
||||
assert.equal(res.body.sessionStatus, 'OPEN');
|
||||
});
|
||||
|
||||
test('GET /query/tenants/:tenantId/businesses/:businessId/favorite-staff validates auth and handler wiring', async () => {
|
||||
const app = createApp({
|
||||
queryService: {
|
||||
listOrders: async () => assert.fail('listOrders should not be called'),
|
||||
getOrderDetail: async () => assert.fail('getOrderDetail should not be called'),
|
||||
listFavoriteStaff: async (params) => {
|
||||
assert.equal(params.tenantId, tenantId);
|
||||
assert.equal(params.businessId, businessId);
|
||||
return [{ staffId, fullName: 'Ana Barista' }];
|
||||
},
|
||||
getStaffReviewSummary: async () => assert.fail('getStaffReviewSummary should not be called'),
|
||||
getAssignmentAttendance: async () => assert.fail('getAssignmentAttendance should not be called'),
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/query/tenants/${tenantId}/businesses/${businessId}/favorite-staff`)
|
||||
.set('Authorization', 'Bearer test-token');
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.items[0].staffId, staffId);
|
||||
});
|
||||
159
backend/query-api/test/mobile-routes.test.js
Normal file
159
backend/query-api/test/mobile-routes.test.js
Normal file
@@ -0,0 +1,159 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../src/app.js';
|
||||
|
||||
process.env.AUTH_BYPASS = 'true';
|
||||
|
||||
function createMobileQueryService() {
|
||||
return {
|
||||
getClientDashboard: async () => ({ businessName: 'Google Cafes' }),
|
||||
getClientSession: async () => ({ business: { businessId: 'b1' } }),
|
||||
getCoverageStats: async () => ({ totalCoveragePercentage: 100 }),
|
||||
getCoverageReport: async () => ({ items: [{ shiftId: 'coverage-1' }] }),
|
||||
getCurrentAttendanceStatus: async () => ({ attendanceStatus: 'NOT_CLOCKED_IN' }),
|
||||
getCurrentBill: async () => ({ currentBillCents: 1000 }),
|
||||
getDailyOpsReport: async () => ({ totals: { workedAssignments: 4 } }),
|
||||
getForecastReport: async () => ({ totals: { projectedCoveragePercentage: 92 } }),
|
||||
getNoShowReport: async () => ({ totals: { noShows: 1 } }),
|
||||
getPaymentChart: async () => ([{ amountCents: 100 }]),
|
||||
getPaymentsSummary: async () => ({ totalEarningsCents: 500 }),
|
||||
getPersonalInfo: async () => ({ firstName: 'Ana' }),
|
||||
getPerformanceReport: async () => ({ totals: { averageRating: 4.8 } }),
|
||||
getProfileSectionsStatus: async () => ({ personalInfoCompleted: true }),
|
||||
getPrivacySettings: async () => ({ profileVisibility: 'TEAM_ONLY' }),
|
||||
getReportSummary: async () => ({ reportDate: '2026-03-13', totals: { orders: 3 } }),
|
||||
getSavings: async () => ({ savingsCents: 200 }),
|
||||
getSpendReport: async () => ({ totals: { amountCents: 2000 } }),
|
||||
getSpendBreakdown: async () => ([{ category: 'Barista', amountCents: 1000 }]),
|
||||
getStaffDashboard: async () => ({ staffName: 'Ana Barista' }),
|
||||
getStaffProfileCompletion: async () => ({ completed: true }),
|
||||
getStaffSession: async () => ({ staff: { staffId: 's1' } }),
|
||||
getStaffShiftDetail: async () => ({ shiftId: 'shift-1' }),
|
||||
listAssignedShifts: async () => ([{ shiftId: 'assigned-1' }]),
|
||||
listBusinessAccounts: async () => ([{ accountId: 'acc-1' }]),
|
||||
listCancelledShifts: async () => ([{ shiftId: 'cancelled-1' }]),
|
||||
listCertificates: async () => ([{ certificateId: 'cert-1' }]),
|
||||
listCostCenters: async () => ([{ costCenterId: 'cc-1' }]),
|
||||
listCoverageByDate: async () => ([{ shiftId: 'coverage-1' }]),
|
||||
listCoreTeam: async () => ([{ staffId: 'core-1' }]),
|
||||
listCompletedShifts: async () => ([{ shiftId: 'completed-1' }]),
|
||||
listEmergencyContacts: async () => ([{ contactId: 'ec-1' }]),
|
||||
listFaqCategories: async () => ([{ id: 'faq-1', title: 'Clock in' }]),
|
||||
listGeofenceIncidents: async () => ([{ incidentId: 'incident-1' }]),
|
||||
listHubManagers: async () => ([{ managerId: 'm1' }]),
|
||||
listHubs: async () => ([{ hubId: 'hub-1' }]),
|
||||
listIndustries: async () => (['CATERING']),
|
||||
listInvoiceHistory: async () => ([{ invoiceId: 'inv-1' }]),
|
||||
listOpenShifts: async () => ([{ shiftId: 'open-1' }]),
|
||||
getOrderReorderPreview: async () => ({ orderId: 'order-1', lines: 2 }),
|
||||
listOrderItemsByDateRange: async () => ([{ itemId: 'item-1' }]),
|
||||
listPaymentsHistory: async () => ([{ paymentId: 'pay-1' }]),
|
||||
listPendingAssignments: async () => ([{ assignmentId: 'asg-1' }]),
|
||||
listPendingInvoices: async () => ([{ invoiceId: 'pending-1' }]),
|
||||
listProfileDocuments: async () => ([{ staffDocumentId: 'doc-1' }]),
|
||||
listRecentReorders: async () => ([{ id: 'order-1' }]),
|
||||
listBusinessTeamMembers: async () => ([{ userId: 'u-1' }]),
|
||||
listSkills: async () => (['BARISTA']),
|
||||
listStaffAvailability: async () => ([{ dayOfWeek: 1 }]),
|
||||
listStaffBankAccounts: async () => ([{ accountId: 'acc-2' }]),
|
||||
listStaffBenefits: async () => ([{ benefitId: 'benefit-1' }]),
|
||||
listTaxForms: async () => ([{ formType: 'W4' }]),
|
||||
listAttireChecklist: async () => ([{ documentId: 'attire-1' }]),
|
||||
listTimeCardEntries: async () => ([{ entryId: 'tc-1' }]),
|
||||
listTodayShifts: async () => ([{ shiftId: 'today-1' }]),
|
||||
listVendorRoles: async () => ([{ roleId: 'role-1' }]),
|
||||
listVendors: async () => ([{ vendorId: 'vendor-1' }]),
|
||||
searchFaqs: async () => ([{ id: 'faq-2', title: 'Payments' }]),
|
||||
};
|
||||
}
|
||||
|
||||
test('GET /query/client/session returns injected client session', async () => {
|
||||
const app = createApp({ mobileQueryService: createMobileQueryService() });
|
||||
const res = await request(app)
|
||||
.get('/query/client/session')
|
||||
.set('Authorization', 'Bearer test-token');
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.business.businessId, 'b1');
|
||||
});
|
||||
|
||||
test('GET /query/client/coverage validates date query param', async () => {
|
||||
const app = createApp({ mobileQueryService: createMobileQueryService() });
|
||||
const res = await request(app)
|
||||
.get('/query/client/coverage')
|
||||
.set('Authorization', 'Bearer test-token');
|
||||
|
||||
assert.equal(res.status, 400);
|
||||
assert.equal(res.body.code, 'VALIDATION_ERROR');
|
||||
});
|
||||
|
||||
test('GET /query/staff/dashboard returns injected dashboard', async () => {
|
||||
const app = createApp({ mobileQueryService: createMobileQueryService() });
|
||||
const res = await request(app)
|
||||
.get('/query/staff/dashboard')
|
||||
.set('Authorization', 'Bearer test-token');
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.staffName, 'Ana Barista');
|
||||
});
|
||||
|
||||
test('GET /query/staff/shifts/:shiftId returns injected shift detail', async () => {
|
||||
const app = createApp({ mobileQueryService: createMobileQueryService() });
|
||||
const res = await request(app)
|
||||
.get('/query/staff/shifts/shift-1')
|
||||
.set('Authorization', 'Bearer test-token');
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.shiftId, 'shift-1');
|
||||
});
|
||||
|
||||
test('GET /query/client/reports/summary returns injected report summary', async () => {
|
||||
const app = createApp({ mobileQueryService: createMobileQueryService() });
|
||||
const res = await request(app)
|
||||
.get('/query/client/reports/summary?date=2026-03-13')
|
||||
.set('Authorization', 'Bearer test-token');
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.totals.orders, 3);
|
||||
});
|
||||
|
||||
test('GET /query/client/coverage/core-team returns injected core team list', async () => {
|
||||
const app = createApp({ mobileQueryService: createMobileQueryService() });
|
||||
const res = await request(app)
|
||||
.get('/query/client/coverage/core-team?date=2026-03-13')
|
||||
.set('Authorization', 'Bearer test-token');
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.items[0].staffId, 'core-1');
|
||||
});
|
||||
|
||||
test('GET /query/client/coverage/incidents returns injected incidents list', async () => {
|
||||
const app = createApp({ mobileQueryService: createMobileQueryService() });
|
||||
const res = await request(app)
|
||||
.get('/query/client/coverage/incidents?startDate=2026-03-01&endDate=2026-03-16')
|
||||
.set('Authorization', 'Bearer test-token');
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.items[0].incidentId, 'incident-1');
|
||||
});
|
||||
|
||||
test('GET /query/staff/profile/tax-forms returns injected tax forms', async () => {
|
||||
const app = createApp({ mobileQueryService: createMobileQueryService() });
|
||||
const res = await request(app)
|
||||
.get('/query/staff/profile/tax-forms')
|
||||
.set('Authorization', 'Bearer test-token');
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.items[0].formType, 'W4');
|
||||
});
|
||||
|
||||
test('GET /query/staff/faqs/search returns injected faq search results', async () => {
|
||||
const app = createApp({ mobileQueryService: createMobileQueryService() });
|
||||
const res = await request(app)
|
||||
.get('/query/staff/faqs/search?q=payments')
|
||||
.set('Authorization', 'Bearer test-token');
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.items[0].title, 'Payments');
|
||||
});
|
||||
13
backend/unified-api/Dockerfile
Normal file
13
backend/unified-api/Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
COPY src ./src
|
||||
|
||||
ENV PORT=8080
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["node", "src/server.js"]
|
||||
3661
backend/unified-api/package-lock.json
generated
Normal file
3661
backend/unified-api/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
backend/unified-api/package.json
Normal file
24
backend/unified-api/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@krow/unified-api",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node src/server.js",
|
||||
"test": "node --test"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.21.2",
|
||||
"firebase-admin": "^13.0.2",
|
||||
"pg": "^8.20.0",
|
||||
"pino": "^9.6.0",
|
||||
"pino-http": "^10.3.0",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"supertest": "^7.0.0"
|
||||
}
|
||||
}
|
||||
64
backend/unified-api/scripts/ensure-v2-demo-users.mjs
Normal file
64
backend/unified-api/scripts/ensure-v2-demo-users.mjs
Normal file
@@ -0,0 +1,64 @@
|
||||
import { signInWithPassword, signUpWithPassword } from '../src/services/identity-toolkit.js';
|
||||
|
||||
const ownerEmail = process.env.V2_DEMO_OWNER_EMAIL || 'legendary.owner+v2@krowd.com';
|
||||
const staffEmail = process.env.V2_DEMO_STAFF_EMAIL || 'ana.barista+v2@krowd.com';
|
||||
const ownerPassword = process.env.V2_DEMO_OWNER_PASSWORD || 'Demo2026!';
|
||||
const staffPassword = process.env.V2_DEMO_STAFF_PASSWORD || 'Demo2026!';
|
||||
|
||||
async function ensureUser({ email, password, displayName }) {
|
||||
try {
|
||||
const signedIn = await signInWithPassword({ email, password });
|
||||
return {
|
||||
uid: signedIn.localId,
|
||||
email,
|
||||
password,
|
||||
created: false,
|
||||
displayName,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error?.message || '';
|
||||
if (!message.includes('INVALID_LOGIN_CREDENTIALS') && !message.includes('EMAIL_NOT_FOUND')) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const signedUp = await signUpWithPassword({ email, password });
|
||||
return {
|
||||
uid: signedUp.localId,
|
||||
email,
|
||||
password,
|
||||
created: true,
|
||||
displayName,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error?.message || '';
|
||||
if (message.includes('EMAIL_EXISTS')) {
|
||||
throw new Error(`Firebase user ${email} exists but password does not match expected demo password.`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const owner = await ensureUser({
|
||||
email: ownerEmail,
|
||||
password: ownerPassword,
|
||||
displayName: 'Legendary Demo Owner V2',
|
||||
});
|
||||
|
||||
const staff = await ensureUser({
|
||||
email: staffEmail,
|
||||
password: staffPassword,
|
||||
displayName: 'Ana Barista V2',
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(JSON.stringify({ owner, staff }, null, 2));
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
1140
backend/unified-api/scripts/live-smoke-v2-unified.mjs
Normal file
1140
backend/unified-api/scripts/live-smoke-v2-unified.mjs
Normal file
File diff suppressed because it is too large
Load Diff
31
backend/unified-api/src/app.js
Normal file
31
backend/unified-api/src/app.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import express from 'express';
|
||||
import pino from 'pino';
|
||||
import pinoHttp from 'pino-http';
|
||||
import { requestContext } from './middleware/request-context.js';
|
||||
import { errorHandler, notFoundHandler } from './middleware/error-handler.js';
|
||||
import { healthRouter } from './routes/health.js';
|
||||
import { createAuthRouter } from './routes/auth.js';
|
||||
import { createProxyRouter } from './routes/proxy.js';
|
||||
|
||||
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
|
||||
|
||||
export function createApp(options = {}) {
|
||||
const app = express();
|
||||
|
||||
app.use(requestContext);
|
||||
app.use(
|
||||
pinoHttp({
|
||||
logger,
|
||||
customProps: (req) => ({ requestId: req.requestId }),
|
||||
})
|
||||
);
|
||||
|
||||
app.use(healthRouter);
|
||||
app.use('/auth', createAuthRouter({ fetchImpl: options.fetchImpl, authService: options.authService }));
|
||||
app.use(createProxyRouter(options));
|
||||
|
||||
app.use(notFoundHandler);
|
||||
app.use(errorHandler);
|
||||
|
||||
return app;
|
||||
}
|
||||
26
backend/unified-api/src/lib/errors.js
Normal file
26
backend/unified-api/src/lib/errors.js
Normal file
@@ -0,0 +1,26 @@
|
||||
export class AppError extends Error {
|
||||
constructor(code, message, status = 400, details = {}) {
|
||||
super(message);
|
||||
this.name = 'AppError';
|
||||
this.code = code;
|
||||
this.status = status;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
export function toErrorEnvelope(error, requestId) {
|
||||
const status = error?.status && Number.isInteger(error.status) ? error.status : 500;
|
||||
const code = error?.code || 'INTERNAL_ERROR';
|
||||
const message = error?.message || 'Unexpected error';
|
||||
const details = error?.details || {};
|
||||
|
||||
return {
|
||||
status,
|
||||
body: {
|
||||
code,
|
||||
message,
|
||||
details,
|
||||
requestId,
|
||||
},
|
||||
};
|
||||
}
|
||||
25
backend/unified-api/src/middleware/error-handler.js
Normal file
25
backend/unified-api/src/middleware/error-handler.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { toErrorEnvelope } from '../lib/errors.js';
|
||||
|
||||
export function notFoundHandler(req, res) {
|
||||
res.status(404).json({
|
||||
code: 'NOT_FOUND',
|
||||
message: `Route not found: ${req.method} ${req.path}`,
|
||||
details: {},
|
||||
requestId: req.requestId,
|
||||
});
|
||||
}
|
||||
|
||||
export function errorHandler(error, req, res, _next) {
|
||||
const envelope = toErrorEnvelope(error, req.requestId);
|
||||
if (req.log) {
|
||||
req.log.error(
|
||||
{
|
||||
errCode: envelope.body.code,
|
||||
status: envelope.status,
|
||||
details: envelope.body.details,
|
||||
},
|
||||
envelope.body.message
|
||||
);
|
||||
}
|
||||
res.status(envelope.status).json(envelope.body);
|
||||
}
|
||||
9
backend/unified-api/src/middleware/request-context.js
Normal file
9
backend/unified-api/src/middleware/request-context.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
export function requestContext(req, res, next) {
|
||||
const incoming = req.get('X-Request-Id');
|
||||
req.requestId = incoming || randomUUID();
|
||||
res.setHeader('X-Request-Id', req.requestId);
|
||||
res.locals.startedAt = Date.now();
|
||||
next();
|
||||
}
|
||||
170
backend/unified-api/src/routes/auth.js
Normal file
170
backend/unified-api/src/routes/auth.js
Normal file
@@ -0,0 +1,170 @@
|
||||
import express from 'express';
|
||||
import { AppError } from '../lib/errors.js';
|
||||
import {
|
||||
getSessionForActor,
|
||||
parseClientSignIn,
|
||||
parseClientSignUp,
|
||||
parseStaffPhoneStart,
|
||||
parseStaffPhoneVerify,
|
||||
signInClient,
|
||||
signOutActor,
|
||||
signUpClient,
|
||||
startStaffPhoneAuth,
|
||||
verifyStaffPhoneAuth,
|
||||
} from '../services/auth-service.js';
|
||||
import { verifyFirebaseToken } from '../services/firebase-auth.js';
|
||||
|
||||
const defaultAuthService = {
|
||||
parseClientSignIn,
|
||||
parseClientSignUp,
|
||||
parseStaffPhoneStart,
|
||||
parseStaffPhoneVerify,
|
||||
signInClient,
|
||||
signOutActor,
|
||||
signUpClient,
|
||||
startStaffPhoneAuth,
|
||||
verifyStaffPhoneAuth,
|
||||
getSessionForActor,
|
||||
};
|
||||
|
||||
function getBearerToken(header) {
|
||||
if (!header) return null;
|
||||
const [scheme, token] = header.split(' ');
|
||||
if (!scheme || scheme.toLowerCase() !== 'bearer' || !token) return null;
|
||||
return token;
|
||||
}
|
||||
|
||||
async function requireAuth(req, _res, next) {
|
||||
try {
|
||||
const token = getBearerToken(req.get('Authorization'));
|
||||
if (!token) {
|
||||
throw new AppError('UNAUTHENTICATED', 'Missing bearer token', 401);
|
||||
}
|
||||
|
||||
if (process.env.AUTH_BYPASS === 'true') {
|
||||
req.actor = { uid: 'test-user', email: 'test@krow.local', role: 'TEST' };
|
||||
return next();
|
||||
}
|
||||
|
||||
const decoded = await verifyFirebaseToken(token);
|
||||
req.actor = {
|
||||
uid: decoded.uid,
|
||||
email: decoded.email || null,
|
||||
role: decoded.role || null,
|
||||
};
|
||||
return next();
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) return next(error);
|
||||
return next(new AppError('UNAUTHENTICATED', 'Token verification failed', 401));
|
||||
}
|
||||
}
|
||||
|
||||
export function createAuthRouter(options = {}) {
|
||||
const router = express.Router();
|
||||
const fetchImpl = options.fetchImpl || fetch;
|
||||
const authService = options.authService || defaultAuthService;
|
||||
|
||||
router.use(express.json({ limit: '1mb' }));
|
||||
|
||||
router.post('/client/sign-in', async (req, res, next) => {
|
||||
try {
|
||||
const payload = authService.parseClientSignIn(req.body);
|
||||
const session = await authService.signInClient(payload, { fetchImpl });
|
||||
return res.status(200).json({
|
||||
...session,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/client/sign-up', async (req, res, next) => {
|
||||
try {
|
||||
const payload = authService.parseClientSignUp(req.body);
|
||||
const session = await authService.signUpClient(payload, { fetchImpl });
|
||||
return res.status(201).json({
|
||||
...session,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/staff/phone/start', async (req, res, next) => {
|
||||
try {
|
||||
const payload = authService.parseStaffPhoneStart(req.body);
|
||||
const result = await authService.startStaffPhoneAuth(payload, { fetchImpl });
|
||||
return res.status(200).json({
|
||||
...result,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/staff/phone/verify', async (req, res, next) => {
|
||||
try {
|
||||
const payload = authService.parseStaffPhoneVerify(req.body);
|
||||
const session = await authService.verifyStaffPhoneAuth(payload, { fetchImpl });
|
||||
return res.status(200).json({
|
||||
...session,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/session', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const session = await authService.getSessionForActor(req.actor);
|
||||
return res.status(200).json({
|
||||
...session,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/sign-out', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const result = await authService.signOutActor(req.actor);
|
||||
return res.status(200).json({
|
||||
...result,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/client/sign-out', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const result = await authService.signOutActor(req.actor);
|
||||
return res.status(200).json({
|
||||
...result,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/staff/sign-out', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const result = await authService.signOutActor(req.actor);
|
||||
return res.status(200).json({
|
||||
...result,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
45
backend/unified-api/src/routes/health.js
Normal file
45
backend/unified-api/src/routes/health.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Router } from 'express';
|
||||
import { checkDatabaseHealth, isDatabaseConfigured } from '../services/db.js';
|
||||
|
||||
export const healthRouter = Router();
|
||||
|
||||
function healthHandler(req, res) {
|
||||
res.status(200).json({
|
||||
ok: true,
|
||||
service: 'krow-api-v2',
|
||||
version: process.env.SERVICE_VERSION || 'dev',
|
||||
requestId: req.requestId,
|
||||
});
|
||||
}
|
||||
|
||||
healthRouter.get('/health', healthHandler);
|
||||
healthRouter.get('/healthz', healthHandler);
|
||||
|
||||
healthRouter.get('/readyz', async (req, res) => {
|
||||
if (!isDatabaseConfigured()) {
|
||||
return res.status(503).json({
|
||||
ok: false,
|
||||
service: 'krow-api-v2',
|
||||
status: 'DATABASE_NOT_CONFIGURED',
|
||||
requestId: req.requestId,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const ok = await checkDatabaseHealth();
|
||||
return res.status(ok ? 200 : 503).json({
|
||||
ok,
|
||||
service: 'krow-api-v2',
|
||||
status: ok ? 'READY' : 'DATABASE_UNAVAILABLE',
|
||||
requestId: req.requestId,
|
||||
});
|
||||
} catch (error) {
|
||||
return res.status(503).json({
|
||||
ok: false,
|
||||
service: 'krow-api-v2',
|
||||
status: 'DATABASE_UNAVAILABLE',
|
||||
details: { message: error.message },
|
||||
requestId: req.requestId,
|
||||
});
|
||||
}
|
||||
});
|
||||
156
backend/unified-api/src/routes/proxy.js
Normal file
156
backend/unified-api/src/routes/proxy.js
Normal file
@@ -0,0 +1,156 @@
|
||||
import { Router } from 'express';
|
||||
import { AppError } from '../lib/errors.js';
|
||||
|
||||
const HOP_BY_HOP_HEADERS = new Set([
|
||||
'connection',
|
||||
'content-length',
|
||||
'host',
|
||||
'keep-alive',
|
||||
'proxy-authenticate',
|
||||
'proxy-authorization',
|
||||
'te',
|
||||
'trailer',
|
||||
'transfer-encoding',
|
||||
'upgrade',
|
||||
]);
|
||||
|
||||
const DIRECT_CORE_ALIASES = [
|
||||
{ methods: new Set(['POST']), pattern: /^\/upload-file$/, targetPath: (pathname) => `/core${pathname}` },
|
||||
{ methods: new Set(['POST']), pattern: /^\/create-signed-url$/, targetPath: (pathname) => `/core${pathname}` },
|
||||
{ methods: new Set(['POST']), pattern: /^\/invoke-llm$/, targetPath: (pathname) => `/core${pathname}` },
|
||||
{ methods: new Set(['POST']), pattern: /^\/rapid-orders\/transcribe$/, targetPath: (pathname) => `/core${pathname}` },
|
||||
{ methods: new Set(['POST']), pattern: /^\/rapid-orders\/parse$/, targetPath: (pathname) => `/core${pathname}` },
|
||||
{ methods: new Set(['POST']), pattern: /^\/staff\/profile\/photo$/, targetPath: (pathname) => `/core${pathname}` },
|
||||
{
|
||||
methods: new Set(['POST']),
|
||||
pattern: /^\/staff\/profile\/documents\/([^/]+)\/upload$/,
|
||||
targetPath: (_pathname, match) => `/core/staff/documents/${match[1]}/upload`,
|
||||
},
|
||||
{
|
||||
methods: new Set(['POST']),
|
||||
pattern: /^\/staff\/profile\/attire\/([^/]+)\/upload$/,
|
||||
targetPath: (_pathname, match) => `/core/staff/attire/${match[1]}/upload`,
|
||||
},
|
||||
{
|
||||
methods: new Set(['POST']),
|
||||
pattern: /^\/staff\/profile\/certificates$/,
|
||||
targetPath: () => '/core/staff/certificates/upload',
|
||||
},
|
||||
{
|
||||
methods: new Set(['DELETE']),
|
||||
pattern: /^\/staff\/profile\/certificates\/([^/]+)$/,
|
||||
targetPath: (_pathname, match) => `/core/staff/certificates/${match[1]}`,
|
||||
},
|
||||
{ methods: new Set(['POST']), pattern: /^\/staff\/documents\/([^/]+)\/upload$/, targetPath: (pathname) => `/core${pathname}` },
|
||||
{ methods: new Set(['POST']), pattern: /^\/staff\/attire\/([^/]+)\/upload$/, targetPath: (pathname) => `/core${pathname}` },
|
||||
{ methods: new Set(['POST']), pattern: /^\/staff\/certificates\/upload$/, targetPath: (pathname) => `/core${pathname}` },
|
||||
{ methods: new Set(['DELETE']), pattern: /^\/staff\/certificates\/([^/]+)$/, targetPath: (pathname) => `/core${pathname}` },
|
||||
{ methods: new Set(['POST']), pattern: /^\/verifications$/, targetPath: (pathname) => `/core${pathname}` },
|
||||
{ methods: new Set(['GET']), pattern: /^\/verifications\/([^/]+)$/, targetPath: (pathname) => `/core${pathname}` },
|
||||
{ methods: new Set(['POST']), pattern: /^\/verifications\/([^/]+)\/review$/, targetPath: (pathname) => `/core${pathname}` },
|
||||
{ methods: new Set(['POST']), pattern: /^\/verifications\/([^/]+)\/retry$/, targetPath: (pathname) => `/core${pathname}` },
|
||||
];
|
||||
|
||||
function resolveTarget(pathname, method) {
|
||||
const upperMethod = method.toUpperCase();
|
||||
|
||||
if (pathname.startsWith('/core')) {
|
||||
return {
|
||||
baseUrl: process.env.CORE_API_BASE_URL,
|
||||
upstreamPath: pathname,
|
||||
};
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/commands')) {
|
||||
return {
|
||||
baseUrl: process.env.COMMAND_API_BASE_URL,
|
||||
upstreamPath: pathname,
|
||||
};
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/query')) {
|
||||
return {
|
||||
baseUrl: process.env.QUERY_API_BASE_URL,
|
||||
upstreamPath: pathname,
|
||||
};
|
||||
}
|
||||
|
||||
for (const alias of DIRECT_CORE_ALIASES) {
|
||||
if (!alias.methods.has(upperMethod)) continue;
|
||||
const match = pathname.match(alias.pattern);
|
||||
if (!match) continue;
|
||||
return {
|
||||
baseUrl: process.env.CORE_API_BASE_URL,
|
||||
upstreamPath: alias.targetPath(pathname, match),
|
||||
};
|
||||
}
|
||||
|
||||
if ((upperMethod === 'GET' || upperMethod === 'HEAD') && (pathname.startsWith('/client') || pathname.startsWith('/staff'))) {
|
||||
return {
|
||||
baseUrl: process.env.QUERY_API_BASE_URL,
|
||||
upstreamPath: `/query${pathname}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(upperMethod) && (pathname.startsWith('/client') || pathname.startsWith('/staff'))) {
|
||||
return {
|
||||
baseUrl: process.env.COMMAND_API_BASE_URL,
|
||||
upstreamPath: `/commands${pathname}`,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function copyHeaders(source, target) {
|
||||
for (const [key, value] of source.entries()) {
|
||||
if (HOP_BY_HOP_HEADERS.has(key.toLowerCase())) continue;
|
||||
target.setHeader(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
async function forwardRequest(req, res, next, fetchImpl) {
|
||||
try {
|
||||
const requestUrl = new URL(req.originalUrl, 'http://localhost');
|
||||
const target = resolveTarget(requestUrl.pathname, req.method);
|
||||
if (!target?.baseUrl) {
|
||||
throw new AppError('NOT_FOUND', `No upstream configured for ${requestUrl.pathname}`, 404);
|
||||
}
|
||||
|
||||
const url = new URL(`${target.upstreamPath}${requestUrl.search}`, target.baseUrl);
|
||||
const headers = new Headers();
|
||||
for (const [key, value] of Object.entries(req.headers)) {
|
||||
if (value == null || HOP_BY_HOP_HEADERS.has(key.toLowerCase())) continue;
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) headers.append(key, item);
|
||||
} else {
|
||||
headers.set(key, value);
|
||||
}
|
||||
}
|
||||
headers.set('x-request-id', req.requestId);
|
||||
|
||||
const upstream = await fetchImpl(url, {
|
||||
method: req.method,
|
||||
headers,
|
||||
body: req.method === 'GET' || req.method === 'HEAD' ? undefined : req,
|
||||
duplex: req.method === 'GET' || req.method === 'HEAD' ? undefined : 'half',
|
||||
});
|
||||
|
||||
copyHeaders(upstream.headers, res);
|
||||
res.status(upstream.status);
|
||||
|
||||
const buffer = Buffer.from(await upstream.arrayBuffer());
|
||||
return res.send(buffer);
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export function createProxyRouter(options = {}) {
|
||||
const router = Router();
|
||||
const fetchImpl = options.fetchImpl || fetch;
|
||||
|
||||
router.use((req, res, next) => forwardRequest(req, res, next, fetchImpl));
|
||||
|
||||
return router;
|
||||
}
|
||||
9
backend/unified-api/src/server.js
Normal file
9
backend/unified-api/src/server.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { createApp } from './app.js';
|
||||
|
||||
const port = Number(process.env.PORT || 8080);
|
||||
const app = createApp();
|
||||
|
||||
app.listen(port, () => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`krow-api-v2 listening on port ${port}`);
|
||||
});
|
||||
304
backend/unified-api/src/services/auth-service.js
Normal file
304
backend/unified-api/src/services/auth-service.js
Normal file
@@ -0,0 +1,304 @@
|
||||
import { z } from 'zod';
|
||||
import { AppError } from '../lib/errors.js';
|
||||
import { withTransaction } from './db.js';
|
||||
import { verifyFirebaseToken, revokeUserSessions } from './firebase-auth.js';
|
||||
import {
|
||||
deleteAccount,
|
||||
sendVerificationCode,
|
||||
signInWithPassword,
|
||||
signInWithPhoneNumber,
|
||||
signUpWithPassword,
|
||||
} from './identity-toolkit.js';
|
||||
import { loadActorContext } from './user-context.js';
|
||||
|
||||
const clientSignInSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
});
|
||||
|
||||
const clientSignUpSchema = z.object({
|
||||
companyName: z.string().min(2).max(120),
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
displayName: z.string().min(2).max(120).optional(),
|
||||
});
|
||||
|
||||
const staffPhoneStartSchema = z.object({
|
||||
phoneNumber: z.string().min(6).max(40),
|
||||
recaptchaToken: z.string().min(1).optional(),
|
||||
iosReceipt: z.string().min(1).optional(),
|
||||
iosSecret: z.string().min(1).optional(),
|
||||
captchaResponse: z.string().min(1).optional(),
|
||||
playIntegrityToken: z.string().min(1).optional(),
|
||||
safetyNetToken: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
const staffPhoneVerifySchema = z.object({
|
||||
mode: z.enum(['sign-in', 'sign-up']).optional(),
|
||||
idToken: z.string().min(1).optional(),
|
||||
sessionInfo: z.string().min(1).optional(),
|
||||
code: z.string().min(1).optional(),
|
||||
}).superRefine((value, ctx) => {
|
||||
if (value.idToken) return;
|
||||
if (value.sessionInfo && value.code) return;
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Provide idToken or sessionInfo and code',
|
||||
});
|
||||
});
|
||||
|
||||
function slugify(input) {
|
||||
return input
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 50);
|
||||
}
|
||||
|
||||
function buildAuthEnvelope(authPayload, context) {
|
||||
return {
|
||||
sessionToken: authPayload.idToken,
|
||||
refreshToken: authPayload.refreshToken,
|
||||
expiresInSeconds: Number.parseInt(`${authPayload.expiresIn || 3600}`, 10),
|
||||
user: {
|
||||
id: context.user?.userId || authPayload.localId,
|
||||
email: context.user?.email || null,
|
||||
displayName: context.user?.displayName || null,
|
||||
phone: context.user?.phone || null,
|
||||
},
|
||||
tenant: context.tenant,
|
||||
business: context.business,
|
||||
vendor: context.vendor,
|
||||
staff: context.staff,
|
||||
requiresProfileSetup: !context.staff,
|
||||
};
|
||||
}
|
||||
|
||||
async function upsertUserFromDecodedToken(decoded, fallbackProfile = {}) {
|
||||
await withTransaction(async (client) => {
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO users (id, email, display_name, phone, status, metadata)
|
||||
VALUES ($1, $2, $3, $4, 'ACTIVE', COALESCE($5::jsonb, '{}'::jsonb))
|
||||
ON CONFLICT (id) DO UPDATE
|
||||
SET email = COALESCE(EXCLUDED.email, users.email),
|
||||
display_name = COALESCE(EXCLUDED.display_name, users.display_name),
|
||||
phone = COALESCE(EXCLUDED.phone, users.phone),
|
||||
metadata = COALESCE(users.metadata, '{}'::jsonb) || COALESCE(EXCLUDED.metadata, '{}'::jsonb),
|
||||
updated_at = NOW()
|
||||
`,
|
||||
[
|
||||
decoded.uid,
|
||||
decoded.email || fallbackProfile.email || null,
|
||||
decoded.name || fallbackProfile.displayName || fallbackProfile.email || decoded.phone_number || null,
|
||||
decoded.phone_number || fallbackProfile.phoneNumber || null,
|
||||
JSON.stringify({
|
||||
provider: decoded.firebase?.sign_in_provider || fallbackProfile.provider || null,
|
||||
}),
|
||||
]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function hydrateAuthContext(authPayload, fallbackProfile = {}) {
|
||||
const decoded = await verifyFirebaseToken(authPayload.idToken);
|
||||
await upsertUserFromDecodedToken(decoded, fallbackProfile);
|
||||
const context = await loadActorContext(decoded.uid);
|
||||
return buildAuthEnvelope(authPayload, context);
|
||||
}
|
||||
|
||||
export function parseClientSignIn(body) {
|
||||
const parsed = clientSignInSchema.safeParse(body || {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError('VALIDATION_ERROR', 'Invalid client sign-in payload', 400, {
|
||||
issues: parsed.error.issues,
|
||||
});
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
export function parseClientSignUp(body) {
|
||||
const parsed = clientSignUpSchema.safeParse(body || {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError('VALIDATION_ERROR', 'Invalid client sign-up payload', 400, {
|
||||
issues: parsed.error.issues,
|
||||
});
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
export function parseStaffPhoneStart(body) {
|
||||
const parsed = staffPhoneStartSchema.safeParse(body || {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError('VALIDATION_ERROR', 'Invalid staff phone start payload', 400, {
|
||||
issues: parsed.error.issues,
|
||||
});
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
export function parseStaffPhoneVerify(body) {
|
||||
const parsed = staffPhoneVerifySchema.safeParse(body || {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError('VALIDATION_ERROR', 'Invalid staff phone verify payload', 400, {
|
||||
issues: parsed.error.issues,
|
||||
});
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
export async function getSessionForActor(actor) {
|
||||
return loadActorContext(actor.uid);
|
||||
}
|
||||
|
||||
export async function signInClient(payload, { fetchImpl = fetch } = {}) {
|
||||
const authPayload = await signInWithPassword(payload, fetchImpl);
|
||||
const decoded = await verifyFirebaseToken(authPayload.idToken);
|
||||
await upsertUserFromDecodedToken(decoded, payload);
|
||||
const context = await loadActorContext(decoded.uid);
|
||||
|
||||
if (!context.user || !context.business) {
|
||||
throw new AppError('FORBIDDEN', 'Authenticated user does not have a client business membership', 403, {
|
||||
uid: decoded.uid,
|
||||
email: decoded.email || null,
|
||||
});
|
||||
}
|
||||
|
||||
return buildAuthEnvelope(authPayload, context);
|
||||
}
|
||||
|
||||
export async function signUpClient(payload, { fetchImpl = fetch } = {}) {
|
||||
const authPayload = await signUpWithPassword(payload, fetchImpl);
|
||||
|
||||
try {
|
||||
const decoded = await verifyFirebaseToken(authPayload.idToken);
|
||||
const defaultDisplayName = payload.displayName || payload.companyName;
|
||||
const tenantSlug = slugify(payload.companyName);
|
||||
const businessSlug = tenantSlug;
|
||||
|
||||
await withTransaction(async (client) => {
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO users (id, email, display_name, status, metadata)
|
||||
VALUES ($1, $2, $3, 'ACTIVE', '{}'::jsonb)
|
||||
ON CONFLICT (id) DO UPDATE
|
||||
SET email = EXCLUDED.email,
|
||||
display_name = EXCLUDED.display_name,
|
||||
updated_at = NOW()
|
||||
`,
|
||||
[decoded.uid, payload.email, defaultDisplayName]
|
||||
);
|
||||
|
||||
const tenantResult = await client.query(
|
||||
`
|
||||
INSERT INTO tenants (slug, name, status, metadata)
|
||||
VALUES ($1, $2, 'ACTIVE', '{"source":"unified-api-sign-up"}'::jsonb)
|
||||
RETURNING id, slug, name
|
||||
`,
|
||||
[tenantSlug, payload.companyName]
|
||||
);
|
||||
const tenant = tenantResult.rows[0];
|
||||
|
||||
const businessResult = await client.query(
|
||||
`
|
||||
INSERT INTO businesses (
|
||||
tenant_id, slug, business_name, status, contact_name, contact_email, metadata
|
||||
)
|
||||
VALUES ($1, $2, $3, 'ACTIVE', $4, $5, '{"source":"unified-api-sign-up"}'::jsonb)
|
||||
RETURNING id, slug, business_name
|
||||
`,
|
||||
[tenant.id, businessSlug, payload.companyName, defaultDisplayName, payload.email]
|
||||
);
|
||||
const business = businessResult.rows[0];
|
||||
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO tenant_memberships (tenant_id, user_id, membership_status, base_role, metadata)
|
||||
VALUES ($1, $2, 'ACTIVE', 'admin', '{"source":"sign-up"}'::jsonb)
|
||||
`,
|
||||
[tenant.id, decoded.uid]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO business_memberships (tenant_id, business_id, user_id, membership_status, business_role, metadata)
|
||||
VALUES ($1, $2, $3, 'ACTIVE', 'owner', '{"source":"sign-up"}'::jsonb)
|
||||
`,
|
||||
[tenant.id, business.id, decoded.uid]
|
||||
);
|
||||
});
|
||||
|
||||
const context = await loadActorContext(decoded.uid);
|
||||
return buildAuthEnvelope(authPayload, context);
|
||||
} catch (error) {
|
||||
await deleteAccount({ idToken: authPayload.idToken }, fetchImpl).catch(() => null);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function shouldUseClientSdkStaffFlow(payload) {
|
||||
return !payload.recaptchaToken && !payload.iosReceipt && !payload.captchaResponse && !payload.playIntegrityToken && !payload.safetyNetToken;
|
||||
}
|
||||
|
||||
export async function startStaffPhoneAuth(payload, { fetchImpl = fetch } = {}) {
|
||||
if (shouldUseClientSdkStaffFlow(payload)) {
|
||||
return {
|
||||
mode: 'CLIENT_FIREBASE_SDK',
|
||||
provider: 'firebase-phone-auth',
|
||||
phoneNumber: payload.phoneNumber,
|
||||
nextStep: 'Complete phone verification in the mobile client, then call /auth/staff/phone/verify with the Firebase idToken.',
|
||||
};
|
||||
}
|
||||
|
||||
const authPayload = await sendVerificationCode(
|
||||
{
|
||||
phoneNumber: payload.phoneNumber,
|
||||
recaptchaToken: payload.recaptchaToken,
|
||||
iosReceipt: payload.iosReceipt,
|
||||
iosSecret: payload.iosSecret,
|
||||
captchaResponse: payload.captchaResponse,
|
||||
playIntegrityToken: payload.playIntegrityToken,
|
||||
safetyNetToken: payload.safetyNetToken,
|
||||
},
|
||||
fetchImpl
|
||||
);
|
||||
|
||||
return {
|
||||
mode: 'IDENTITY_TOOLKIT_SMS',
|
||||
phoneNumber: payload.phoneNumber,
|
||||
sessionInfo: authPayload.sessionInfo,
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifyStaffPhoneAuth(payload, { fetchImpl = fetch } = {}) {
|
||||
if (payload.idToken) {
|
||||
return hydrateAuthContext(
|
||||
{
|
||||
idToken: payload.idToken,
|
||||
refreshToken: null,
|
||||
expiresIn: 3600,
|
||||
},
|
||||
{
|
||||
provider: 'firebase-phone-auth',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const authPayload = await signInWithPhoneNumber(
|
||||
{
|
||||
sessionInfo: payload.sessionInfo,
|
||||
code: payload.code,
|
||||
operation: payload.mode === 'sign-up' ? 'SIGN_UP_OR_IN' : undefined,
|
||||
},
|
||||
fetchImpl
|
||||
);
|
||||
|
||||
return hydrateAuthContext(authPayload, {
|
||||
provider: 'firebase-phone-auth',
|
||||
});
|
||||
}
|
||||
|
||||
export async function signOutActor(actor) {
|
||||
await revokeUserSessions(actor.uid);
|
||||
return { signedOut: true };
|
||||
}
|
||||
87
backend/unified-api/src/services/db.js
Normal file
87
backend/unified-api/src/services/db.js
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Pool } from 'pg';
|
||||
|
||||
let pool;
|
||||
|
||||
function parseIntOrDefault(value, fallback) {
|
||||
const parsed = Number.parseInt(`${value || fallback}`, 10);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function resolveDatabasePoolConfig() {
|
||||
if (process.env.DATABASE_URL) {
|
||||
return {
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
max: parseIntOrDefault(process.env.DB_POOL_MAX, 10),
|
||||
idleTimeoutMillis: parseIntOrDefault(process.env.DB_IDLE_TIMEOUT_MS, 30000),
|
||||
};
|
||||
}
|
||||
|
||||
const user = process.env.DB_USER;
|
||||
const password = process.env.DB_PASSWORD;
|
||||
const database = process.env.DB_NAME;
|
||||
const host = process.env.DB_HOST || (
|
||||
process.env.INSTANCE_CONNECTION_NAME
|
||||
? `/cloudsql/${process.env.INSTANCE_CONNECTION_NAME}`
|
||||
: ''
|
||||
);
|
||||
|
||||
if (!user || password == null || !database || !host) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
host,
|
||||
port: parseIntOrDefault(process.env.DB_PORT, 5432),
|
||||
user,
|
||||
password,
|
||||
database,
|
||||
max: parseIntOrDefault(process.env.DB_POOL_MAX, 10),
|
||||
idleTimeoutMillis: parseIntOrDefault(process.env.DB_IDLE_TIMEOUT_MS, 30000),
|
||||
};
|
||||
}
|
||||
|
||||
export function isDatabaseConfigured() {
|
||||
return Boolean(resolveDatabasePoolConfig());
|
||||
}
|
||||
|
||||
function getPool() {
|
||||
if (!pool) {
|
||||
const resolved = resolveDatabasePoolConfig();
|
||||
if (!resolved) {
|
||||
throw new Error('Database connection settings are required');
|
||||
}
|
||||
pool = new Pool(resolved);
|
||||
}
|
||||
return pool;
|
||||
}
|
||||
|
||||
export async function query(text, params = []) {
|
||||
return getPool().query(text, params);
|
||||
}
|
||||
|
||||
export async function withTransaction(work) {
|
||||
const client = await getPool().connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const result = await work(client);
|
||||
await client.query('COMMIT');
|
||||
return result;
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkDatabaseHealth() {
|
||||
const result = await query('SELECT 1 AS ok');
|
||||
return result.rows[0]?.ok === 1;
|
||||
}
|
||||
|
||||
export async function closePool() {
|
||||
if (pool) {
|
||||
await pool.end();
|
||||
pool = null;
|
||||
}
|
||||
}
|
||||
23
backend/unified-api/src/services/firebase-auth.js
Normal file
23
backend/unified-api/src/services/firebase-auth.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import { applicationDefault, getApps, initializeApp } from 'firebase-admin/app';
|
||||
import { getAuth } from 'firebase-admin/auth';
|
||||
|
||||
function ensureAdminApp() {
|
||||
if (getApps().length === 0) {
|
||||
initializeApp({ credential: applicationDefault() });
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyFirebaseToken(token, { checkRevoked = false } = {}) {
|
||||
ensureAdminApp();
|
||||
return getAuth().verifyIdToken(token, checkRevoked);
|
||||
}
|
||||
|
||||
export async function revokeUserSessions(uid) {
|
||||
ensureAdminApp();
|
||||
await getAuth().revokeRefreshTokens(uid);
|
||||
}
|
||||
|
||||
export async function createCustomToken(uid) {
|
||||
ensureAdminApp();
|
||||
return getAuth().createCustomToken(uid);
|
||||
}
|
||||
92
backend/unified-api/src/services/identity-toolkit.js
Normal file
92
backend/unified-api/src/services/identity-toolkit.js
Normal file
@@ -0,0 +1,92 @@
|
||||
import { AppError } from '../lib/errors.js';
|
||||
|
||||
const IDENTITY_TOOLKIT_BASE_URL = 'https://identitytoolkit.googleapis.com/v1';
|
||||
|
||||
function getApiKey() {
|
||||
const apiKey = process.env.FIREBASE_WEB_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new AppError('CONFIGURATION_ERROR', 'FIREBASE_WEB_API_KEY is required', 500);
|
||||
}
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
async function callIdentityToolkit(path, payload, fetchImpl = fetch) {
|
||||
const response = await fetchImpl(`${IDENTITY_TOOLKIT_BASE_URL}/${path}?key=${getApiKey()}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const json = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new AppError(
|
||||
'AUTH_PROVIDER_ERROR',
|
||||
json?.error?.message || `Identity Toolkit request failed: ${path}`,
|
||||
response.status,
|
||||
{ provider: 'firebase-identity-toolkit', path }
|
||||
);
|
||||
}
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
export async function signInWithPassword({ email, password }, fetchImpl = fetch) {
|
||||
return callIdentityToolkit(
|
||||
'accounts:signInWithPassword',
|
||||
{
|
||||
email,
|
||||
password,
|
||||
returnSecureToken: true,
|
||||
},
|
||||
fetchImpl
|
||||
);
|
||||
}
|
||||
|
||||
export async function signUpWithPassword({ email, password }, fetchImpl = fetch) {
|
||||
return callIdentityToolkit(
|
||||
'accounts:signUp',
|
||||
{
|
||||
email,
|
||||
password,
|
||||
returnSecureToken: true,
|
||||
},
|
||||
fetchImpl
|
||||
);
|
||||
}
|
||||
|
||||
export async function sendVerificationCode(payload, fetchImpl = fetch) {
|
||||
return callIdentityToolkit(
|
||||
'accounts:sendVerificationCode',
|
||||
payload,
|
||||
fetchImpl
|
||||
);
|
||||
}
|
||||
|
||||
export async function signInWithPhoneNumber(payload, fetchImpl = fetch) {
|
||||
return callIdentityToolkit(
|
||||
'accounts:signInWithPhoneNumber',
|
||||
payload,
|
||||
fetchImpl
|
||||
);
|
||||
}
|
||||
|
||||
export async function signInWithCustomToken(payload, fetchImpl = fetch) {
|
||||
return callIdentityToolkit(
|
||||
'accounts:signInWithCustomToken',
|
||||
{
|
||||
token: payload.token,
|
||||
returnSecureToken: true,
|
||||
},
|
||||
fetchImpl
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteAccount({ idToken }, fetchImpl = fetch) {
|
||||
return callIdentityToolkit(
|
||||
'accounts:delete',
|
||||
{ idToken },
|
||||
fetchImpl
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user