Merge pull request #653 from Oloodi/codex/feat-m5-attendance-monitoring-foundation
feat(attendance): add M5 monitoring and notification foundation
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,12 +9,14 @@
|
||||
"scripts": {
|
||||
"start": "node src/server.js",
|
||||
"test": "node --test",
|
||||
"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();
|
||||
}
|
||||
@@ -44,8 +44,8 @@ async function main() {
|
||||
const completedEndsAt = hoursFromNow(-20);
|
||||
const checkedInAt = hoursFromNow(-27.5);
|
||||
const checkedOutAt = hoursFromNow(-20.25);
|
||||
const assignedStartsAt = hoursFromNow(2);
|
||||
const assignedEndsAt = hoursFromNow(10);
|
||||
const assignedStartsAt = hoursFromNow(0.1);
|
||||
const assignedEndsAt = hoursFromNow(8.1);
|
||||
const availableStartsAt = hoursFromNow(30);
|
||||
const availableEndsAt = hoursFromNow(38);
|
||||
const cancelledStartsAt = hoursFromNow(20);
|
||||
@@ -270,9 +270,9 @@ async function main() {
|
||||
`
|
||||
INSERT INTO clock_points (
|
||||
id, tenant_id, business_id, cost_center_id, label, address, latitude, longitude,
|
||||
geofence_radius_meters, nfc_tag_uid, status, metadata
|
||||
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, 'ACTIVE', $11::jsonb)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 'ACTIVE', $13::jsonb)
|
||||
`,
|
||||
[
|
||||
fixture.clockPoint.id,
|
||||
@@ -285,6 +285,8 @@ async function main() {
|
||||
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 }),
|
||||
]
|
||||
);
|
||||
@@ -369,11 +371,12 @@ async function main() {
|
||||
`
|
||||
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, required_workers, assigned_workers, notes, metadata
|
||||
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, 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, 1, 1, 'Completed staffed shift', '{"slice":"completed"}'::jsonb)
|
||||
($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,
|
||||
@@ -407,13 +410,14 @@ async function main() {
|
||||
`
|
||||
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, required_workers, assigned_workers, notes, metadata
|
||||
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, 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, 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, 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, 1, 0, 'No-show historical sample', '{"slice":"no_show"}'::jsonb)
|
||||
($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,
|
||||
@@ -445,6 +449,8 @@ async function main() {
|
||||
fixture.shifts.noShow.title,
|
||||
noShowStartsAt,
|
||||
noShowEndsAt,
|
||||
fixture.shifts.assigned.clockInMode,
|
||||
fixture.shifts.assigned.allowClockInOverride,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -833,6 +839,96 @@ async function main() {
|
||||
]
|
||||
);
|
||||
|
||||
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
|
||||
|
||||
@@ -6,8 +6,8 @@ export const V2DemoFixture = {
|
||||
},
|
||||
users: {
|
||||
businessOwner: {
|
||||
id: process.env.V2_DEMO_OWNER_UID || 'dvpWnaBjT6UksS5lo04hfMTyq1q1',
|
||||
email: process.env.V2_DEMO_OWNER_EMAIL || 'legendary@krowd.com',
|
||||
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: {
|
||||
@@ -21,7 +21,7 @@ export const V2DemoFixture = {
|
||||
displayName: 'Vendor Manager',
|
||||
},
|
||||
staffAna: {
|
||||
id: process.env.V2_DEMO_STAFF_UID || 'demo-staff-ana',
|
||||
id: process.env.V2_DEMO_STAFF_UID || 'vwptrLl5S2Z598WP93cgrQEzqBg1',
|
||||
email: process.env.V2_DEMO_STAFF_EMAIL || 'ana.barista+v2@krowd.com',
|
||||
displayName: 'Ana Barista',
|
||||
},
|
||||
@@ -77,6 +77,8 @@ export const V2DemoFixture = {
|
||||
longitude: -122.0841,
|
||||
geofenceRadiusMeters: 120,
|
||||
nfcTagUid: 'NFC-DEMO-ANA-001',
|
||||
defaultClockInMode: 'GEO_REQUIRED',
|
||||
allowClockInOverride: true,
|
||||
},
|
||||
hubManagers: {
|
||||
opsLead: {
|
||||
@@ -134,6 +136,8 @@ export const V2DemoFixture = {
|
||||
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',
|
||||
@@ -268,4 +272,19 @@ export const V2DemoFixture = {
|
||||
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',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
@@ -16,6 +16,9 @@ const preferredLocationSchema = z.object({
|
||||
|
||||
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(),
|
||||
@@ -68,6 +71,8 @@ export const hubCreateSchema = z.object({
|
||||
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({
|
||||
@@ -202,7 +207,13 @@ export const staffClockInSchema = z.object({
|
||||
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',
|
||||
@@ -220,13 +231,61 @@ export const staffClockOutSchema = z.object({
|
||||
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(),
|
||||
|
||||
@@ -21,12 +21,17 @@ import {
|
||||
disputeInvoice,
|
||||
quickSetStaffAvailability,
|
||||
rateWorkerFromCoverage,
|
||||
registerClientPushToken,
|
||||
registerStaffPushToken,
|
||||
requestShiftSwap,
|
||||
saveTaxFormDraft,
|
||||
setupStaffProfile,
|
||||
staffClockIn,
|
||||
staffClockOut,
|
||||
submitLocationStreamBatch,
|
||||
submitTaxForm,
|
||||
unregisterClientPushToken,
|
||||
unregisterStaffPushToken,
|
||||
updateEmergencyContact,
|
||||
updateHub,
|
||||
updatePersonalInfo,
|
||||
@@ -61,10 +66,13 @@ import {
|
||||
preferredLocationsUpdateSchema,
|
||||
privacyUpdateSchema,
|
||||
profileExperienceSchema,
|
||||
pushTokenDeleteSchema,
|
||||
pushTokenRegisterSchema,
|
||||
shiftApplySchema,
|
||||
shiftDecisionSchema,
|
||||
staffClockInSchema,
|
||||
staffClockOutSchema,
|
||||
staffLocationBatchSchema,
|
||||
staffProfileSetupSchema,
|
||||
taxFormDraftSchema,
|
||||
taxFormSubmitSchema,
|
||||
@@ -89,12 +97,17 @@ const defaultHandlers = {
|
||||
disputeInvoice,
|
||||
quickSetStaffAvailability,
|
||||
rateWorkerFromCoverage,
|
||||
registerClientPushToken,
|
||||
registerStaffPushToken,
|
||||
requestShiftSwap,
|
||||
saveTaxFormDraft,
|
||||
setupStaffProfile,
|
||||
staffClockIn,
|
||||
staffClockOut,
|
||||
submitLocationStreamBatch,
|
||||
submitTaxForm,
|
||||
unregisterClientPushToken,
|
||||
unregisterStaffPushToken,
|
||||
updateEmergencyContact,
|
||||
updateHub,
|
||||
updatePersonalInfo,
|
||||
@@ -282,6 +295,26 @@ export function createMobileCommandsRouter(handlers = defaultHandlers) {
|
||||
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',
|
||||
@@ -296,6 +329,33 @@ export function createMobileCommandsRouter(handlers = defaultHandlers) {
|
||||
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',
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,36 +1,14 @@
|
||||
import { AppError } from '../lib/errors.js';
|
||||
import { withTransaction } from './db.js';
|
||||
import { recordGeofenceIncident } from './attendance-monitoring.js';
|
||||
import { recordAttendanceSecurityProof } from './attendance-security.js';
|
||||
import { evaluateClockInAttempt } from './clock-in-policy.js';
|
||||
import { enqueueHubManagerAlert } from './notification-outbox.js';
|
||||
|
||||
function toIsoOrNull(value) {
|
||||
return value ? new Date(value).toISOString() : null;
|
||||
}
|
||||
|
||||
function toRadians(value) {
|
||||
return (value * Math.PI) / 180;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
const ACTIVE_ASSIGNMENT_STATUSES = new Set([
|
||||
'ASSIGNED',
|
||||
'ACCEPTED',
|
||||
@@ -179,10 +157,14 @@ async function requireAssignment(client, assignmentId) {
|
||||
s.title AS shift_title,
|
||||
s.starts_at,
|
||||
s.ends_at,
|
||||
s.clock_in_mode,
|
||||
s.allow_clock_in_override,
|
||||
cp.nfc_tag_uid AS expected_nfc_tag_uid,
|
||||
cp.latitude AS expected_latitude,
|
||||
cp.longitude AS expected_longitude,
|
||||
cp.geofence_radius_meters
|
||||
COALESCE(s.latitude, cp.latitude) AS expected_latitude,
|
||||
COALESCE(s.longitude, cp.longitude) AS expected_longitude,
|
||||
COALESCE(s.geofence_radius_meters, cp.geofence_radius_meters) AS geofence_radius_meters,
|
||||
cp.default_clock_in_mode,
|
||||
cp.allow_clock_in_override AS default_allow_clock_in_override
|
||||
FROM assignments a
|
||||
JOIN shifts s ON s.id = a.shift_id
|
||||
LEFT JOIN clock_points cp ON cp.id = s.clock_point_id
|
||||
@@ -1106,53 +1088,46 @@ export async function assignStaffToShift(actor, payload) {
|
||||
});
|
||||
}
|
||||
|
||||
function buildAttendanceValidation(assignment, payload) {
|
||||
const expectedPoint = {
|
||||
latitude: assignment.expected_latitude,
|
||||
longitude: assignment.expected_longitude,
|
||||
};
|
||||
const actualPoint = {
|
||||
latitude: payload.latitude,
|
||||
longitude: payload.longitude,
|
||||
};
|
||||
const distance = distanceMeters(actualPoint, expectedPoint);
|
||||
const expectedNfcTag = assignment.expected_nfc_tag_uid;
|
||||
const radius = assignment.geofence_radius_meters;
|
||||
|
||||
let validationStatus = 'ACCEPTED';
|
||||
let validationReason = null;
|
||||
|
||||
if (expectedNfcTag && payload.sourceType === 'NFC' && payload.nfcTagUid !== expectedNfcTag) {
|
||||
validationStatus = 'REJECTED';
|
||||
validationReason = 'NFC tag mismatch';
|
||||
}
|
||||
|
||||
if (
|
||||
validationStatus === 'ACCEPTED'
|
||||
&& distance != null
|
||||
&& radius != null
|
||||
&& distance > radius
|
||||
) {
|
||||
validationStatus = 'REJECTED';
|
||||
validationReason = `Outside geofence by ${distance - radius} meters`;
|
||||
}
|
||||
|
||||
return {
|
||||
distance,
|
||||
validationStatus,
|
||||
validationReason,
|
||||
withinGeofence: distance == null || radius == null ? null : distance <= radius,
|
||||
};
|
||||
}
|
||||
|
||||
async function createAttendanceEvent(actor, payload, eventType) {
|
||||
return withTransaction(async (client) => {
|
||||
await ensureActorUser(client, actor);
|
||||
const assignment = await requireAssignment(client, payload.assignmentId);
|
||||
const validation = buildAttendanceValidation(assignment, payload);
|
||||
const capturedAt = toIsoOrNull(payload.capturedAt) || new Date().toISOString();
|
||||
let securityProof = null;
|
||||
|
||||
if (validation.validationStatus === 'REJECTED') {
|
||||
async function rejectAttendanceAttempt({
|
||||
errorCode,
|
||||
reason,
|
||||
incidentType = 'CLOCK_IN_REJECTED',
|
||||
severity = 'WARNING',
|
||||
effectiveClockInMode = null,
|
||||
distance = null,
|
||||
withinGeofence = null,
|
||||
metadata = {},
|
||||
details = {},
|
||||
}) {
|
||||
const incidentId = await recordGeofenceIncident(client, {
|
||||
assignment,
|
||||
actorUserId: actor.uid,
|
||||
incidentType,
|
||||
severity,
|
||||
effectiveClockInMode,
|
||||
sourceType: payload.sourceType,
|
||||
nfcTagUid: payload.nfcTagUid || null,
|
||||
deviceId: payload.deviceId || null,
|
||||
latitude: payload.latitude ?? null,
|
||||
longitude: payload.longitude ?? null,
|
||||
accuracyMeters: payload.accuracyMeters ?? null,
|
||||
distanceToClockPointMeters: distance,
|
||||
withinGeofence,
|
||||
overrideReason: payload.overrideReason || null,
|
||||
message: reason,
|
||||
occurredAt: capturedAt,
|
||||
metadata: {
|
||||
eventType,
|
||||
...metadata,
|
||||
},
|
||||
});
|
||||
const rejectedEvent = await client.query(
|
||||
`
|
||||
INSERT INTO attendance_events (
|
||||
@@ -1195,11 +1170,15 @@ async function createAttendanceEvent(actor, payload, eventType) {
|
||||
payload.latitude ?? null,
|
||||
payload.longitude ?? null,
|
||||
payload.accuracyMeters ?? null,
|
||||
validation.distance,
|
||||
validation.withinGeofence,
|
||||
validation.validationReason,
|
||||
distance,
|
||||
withinGeofence,
|
||||
reason,
|
||||
capturedAt,
|
||||
JSON.stringify(payload.rawPayload || {}),
|
||||
JSON.stringify({
|
||||
...(payload.rawPayload || {}),
|
||||
securityProofId: securityProof?.proofId || null,
|
||||
securityObjectUri: securityProof?.objectUri || null,
|
||||
}),
|
||||
]
|
||||
);
|
||||
|
||||
@@ -1212,14 +1191,70 @@ async function createAttendanceEvent(actor, payload, eventType) {
|
||||
payload: {
|
||||
assignmentId: assignment.id,
|
||||
sourceType: payload.sourceType,
|
||||
validationReason: validation.validationReason,
|
||||
reason,
|
||||
incidentId,
|
||||
...details,
|
||||
},
|
||||
});
|
||||
|
||||
throw new AppError('ATTENDANCE_VALIDATION_FAILED', validation.validationReason, 409, {
|
||||
throw new AppError(errorCode, reason, 409, {
|
||||
assignmentId: payload.assignmentId,
|
||||
attendanceEventId: rejectedEvent.rows[0].id,
|
||||
distanceToClockPointMeters: validation.distance,
|
||||
distanceToClockPointMeters: distance,
|
||||
effectiveClockInMode,
|
||||
...details,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
securityProof = await recordAttendanceSecurityProof(client, {
|
||||
assignment,
|
||||
actor,
|
||||
payload,
|
||||
eventType,
|
||||
capturedAt,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!(error instanceof AppError) || error.code !== 'ATTENDANCE_SECURITY_FAILED') {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await rejectAttendanceAttempt({
|
||||
errorCode: error.code,
|
||||
reason: error.message,
|
||||
incidentType: 'CLOCK_IN_REJECTED',
|
||||
severity: error.details?.securityCode?.startsWith('NFC') ? 'CRITICAL' : 'WARNING',
|
||||
effectiveClockInMode: assignment.clock_in_mode || assignment.default_clock_in_mode || null,
|
||||
metadata: {
|
||||
securityCode: error.details?.securityCode || null,
|
||||
},
|
||||
details: {
|
||||
securityCode: error.details?.securityCode || null,
|
||||
securityProofId: error.details?.proofId || null,
|
||||
securityObjectUri: error.details?.objectUri || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const validation = evaluateClockInAttempt(assignment, payload);
|
||||
|
||||
if (validation.validationStatus === 'REJECTED') {
|
||||
await rejectAttendanceAttempt({
|
||||
errorCode: 'ATTENDANCE_VALIDATION_FAILED',
|
||||
reason: validation.validationReason,
|
||||
incidentType: validation.validationCode === 'NFC_MISMATCH'
|
||||
? 'NFC_MISMATCH'
|
||||
: 'CLOCK_IN_REJECTED',
|
||||
severity: validation.validationCode === 'NFC_MISMATCH' ? 'CRITICAL' : 'WARNING',
|
||||
effectiveClockInMode: validation.effectiveClockInMode,
|
||||
distance: validation.distance,
|
||||
withinGeofence: validation.withinGeofence,
|
||||
metadata: {
|
||||
validationCode: validation.validationCode,
|
||||
},
|
||||
details: {
|
||||
validationCode: validation.validationCode,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1289,12 +1324,65 @@ async function createAttendanceEvent(actor, payload, eventType) {
|
||||
validation.distance,
|
||||
validation.withinGeofence,
|
||||
validation.validationStatus,
|
||||
validation.validationReason,
|
||||
validation.overrideUsed ? validation.overrideReason : validation.validationReason,
|
||||
capturedAt,
|
||||
JSON.stringify(payload.rawPayload || {}),
|
||||
JSON.stringify({
|
||||
...(payload.rawPayload || {}),
|
||||
securityProofId: securityProof?.proofId || null,
|
||||
securityAttestationStatus: securityProof?.attestationStatus || null,
|
||||
securityObjectUri: securityProof?.objectUri || null,
|
||||
}),
|
||||
]
|
||||
);
|
||||
|
||||
if (validation.overrideUsed) {
|
||||
const incidentId = await recordGeofenceIncident(client, {
|
||||
assignment,
|
||||
actorUserId: actor.uid,
|
||||
incidentType: 'CLOCK_IN_OVERRIDE',
|
||||
severity: 'WARNING',
|
||||
effectiveClockInMode: validation.effectiveClockInMode,
|
||||
sourceType: payload.sourceType,
|
||||
nfcTagUid: payload.nfcTagUid || null,
|
||||
deviceId: payload.deviceId || null,
|
||||
latitude: payload.latitude ?? null,
|
||||
longitude: payload.longitude ?? null,
|
||||
accuracyMeters: payload.accuracyMeters ?? null,
|
||||
distanceToClockPointMeters: validation.distance,
|
||||
withinGeofence: validation.withinGeofence,
|
||||
overrideReason: validation.overrideReason,
|
||||
message: validation.validationReason,
|
||||
occurredAt: capturedAt,
|
||||
metadata: {
|
||||
validationCode: validation.validationCode,
|
||||
eventType,
|
||||
},
|
||||
});
|
||||
|
||||
await enqueueHubManagerAlert(client, {
|
||||
tenantId: assignment.tenant_id,
|
||||
businessId: assignment.business_id,
|
||||
shiftId: assignment.shift_id,
|
||||
assignmentId: assignment.id,
|
||||
hubId: assignment.clock_point_id,
|
||||
relatedIncidentId: incidentId,
|
||||
notificationType: 'CLOCK_IN_OVERRIDE_REVIEW',
|
||||
priority: 'HIGH',
|
||||
subject: 'Clock-in override requires review',
|
||||
body: `${assignment.shift_title}: clock-in override submitted by ${actor.email || actor.uid}`,
|
||||
payload: {
|
||||
assignmentId: assignment.id,
|
||||
shiftId: assignment.shift_id,
|
||||
staffId: assignment.staff_id,
|
||||
reason: validation.overrideReason,
|
||||
validationReason: validation.validationReason,
|
||||
effectiveClockInMode: validation.effectiveClockInMode,
|
||||
eventType,
|
||||
},
|
||||
dedupeScope: incidentId,
|
||||
});
|
||||
}
|
||||
|
||||
let sessionId;
|
||||
if (eventType === 'CLOCK_IN') {
|
||||
const insertedSession = await client.query(
|
||||
@@ -1360,6 +1448,7 @@ async function createAttendanceEvent(actor, payload, eventType) {
|
||||
assignmentId: assignment.id,
|
||||
sessionId,
|
||||
sourceType: payload.sourceType,
|
||||
validationStatus: validation.validationStatus,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1369,6 +1458,10 @@ async function createAttendanceEvent(actor, payload, eventType) {
|
||||
sessionId,
|
||||
status: eventType,
|
||||
validationStatus: eventResult.rows[0].validation_status,
|
||||
effectiveClockInMode: validation.effectiveClockInMode,
|
||||
overrideUsed: validation.overrideUsed,
|
||||
securityProofId: securityProof?.proofId || null,
|
||||
attestationStatus: securityProof?.attestationStatus || 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);
|
||||
}
|
||||
|
||||
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}`;
|
||||
}
|
||||
@@ -2,6 +2,11 @@ import crypto from 'node:crypto';
|
||||
import { AppError } from '../lib/errors.js';
|
||||
import { query, withTransaction } from './db.js';
|
||||
import { loadActorContext, requireClientContext, requireStaffContext } from './actor-context.js';
|
||||
import { recordGeofenceIncident } from './attendance-monitoring.js';
|
||||
import { distanceMeters, resolveEffectiveClockInPolicy } from './clock-in-policy.js';
|
||||
import { uploadLocationBatch } from './location-log-storage.js';
|
||||
import { enqueueHubManagerAlert, enqueueUserAlert } from './notification-outbox.js';
|
||||
import { registerPushToken, unregisterPushToken } from './notification-device-tokens.js';
|
||||
import {
|
||||
cancelOrder as cancelOrderCommand,
|
||||
clockIn as clockInCommand,
|
||||
@@ -30,6 +35,17 @@ function ensureArray(value) {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function buildAssignmentReferencePayload(assignment) {
|
||||
return {
|
||||
assignmentId: assignment.id,
|
||||
shiftId: assignment.shift_id,
|
||||
businessId: assignment.business_id,
|
||||
vendorId: assignment.vendor_id,
|
||||
staffId: assignment.staff_id,
|
||||
clockPointId: assignment.clock_point_id,
|
||||
};
|
||||
}
|
||||
|
||||
function generateOrderNumber(prefix = 'ORD') {
|
||||
const stamp = Date.now().toString().slice(-8);
|
||||
const random = crypto.randomInt(100, 999);
|
||||
@@ -460,6 +476,51 @@ async function resolveStaffAssignmentForClock(actorUid, tenantId, payload, { req
|
||||
throw new AppError('NOT_FOUND', 'No assignment found for the current staff clock action', 404, payload);
|
||||
}
|
||||
|
||||
async function loadAssignmentMonitoringContext(client, tenantId, assignmentId, actorUid) {
|
||||
const result = await client.query(
|
||||
`
|
||||
SELECT
|
||||
a.id,
|
||||
a.tenant_id,
|
||||
a.business_id,
|
||||
a.vendor_id,
|
||||
a.shift_id,
|
||||
a.shift_role_id,
|
||||
a.staff_id,
|
||||
a.status,
|
||||
s.clock_point_id,
|
||||
s.title AS shift_title,
|
||||
s.starts_at,
|
||||
s.ends_at,
|
||||
s.clock_in_mode,
|
||||
s.allow_clock_in_override,
|
||||
cp.default_clock_in_mode,
|
||||
cp.allow_clock_in_override AS default_allow_clock_in_override,
|
||||
cp.nfc_tag_uid AS expected_nfc_tag_uid,
|
||||
COALESCE(s.latitude, cp.latitude) AS expected_latitude,
|
||||
COALESCE(s.longitude, cp.longitude) AS expected_longitude,
|
||||
COALESCE(s.geofence_radius_meters, cp.geofence_radius_meters) AS geofence_radius_meters
|
||||
FROM assignments a
|
||||
JOIN staffs st ON st.id = a.staff_id
|
||||
JOIN shifts s ON s.id = a.shift_id
|
||||
LEFT JOIN clock_points cp ON cp.id = s.clock_point_id
|
||||
WHERE a.tenant_id = $1
|
||||
AND a.id = $2
|
||||
AND st.user_id = $3
|
||||
FOR UPDATE OF a, s
|
||||
`,
|
||||
[tenantId, assignmentId, actorUid]
|
||||
);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
throw new AppError('NOT_FOUND', 'Assignment not found in staff scope', 404, {
|
||||
assignmentId,
|
||||
});
|
||||
}
|
||||
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
async function ensureActorUser(client, actor, fields = {}) {
|
||||
await client.query(
|
||||
`
|
||||
@@ -541,7 +602,20 @@ async function requireBusinessMembership(client, businessId, userId) {
|
||||
async function requireClockPoint(client, tenantId, businessId, hubId, { forUpdate = false } = {}) {
|
||||
const result = await client.query(
|
||||
`
|
||||
SELECT id, tenant_id, business_id, label, status, cost_center_id, nfc_tag_uid, metadata
|
||||
SELECT
|
||||
id,
|
||||
tenant_id,
|
||||
business_id,
|
||||
label,
|
||||
status,
|
||||
cost_center_id,
|
||||
nfc_tag_uid,
|
||||
latitude,
|
||||
longitude,
|
||||
geofence_radius_meters,
|
||||
default_clock_in_mode,
|
||||
allow_clock_in_override,
|
||||
metadata
|
||||
FROM clock_points
|
||||
WHERE id = $1
|
||||
AND tenant_id = $2
|
||||
@@ -868,11 +942,13 @@ export async function createHub(actor, payload) {
|
||||
longitude,
|
||||
geofence_radius_meters,
|
||||
nfc_tag_uid,
|
||||
default_clock_in_mode,
|
||||
allow_clock_in_override,
|
||||
cost_center_id,
|
||||
status,
|
||||
metadata
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, 120), $8, $9, 'ACTIVE', $10::jsonb)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, 120), $8, COALESCE($9, 'EITHER'), COALESCE($10, TRUE), $11, 'ACTIVE', $12::jsonb)
|
||||
RETURNING id
|
||||
`,
|
||||
[
|
||||
@@ -884,6 +960,8 @@ export async function createHub(actor, payload) {
|
||||
payload.longitude ?? null,
|
||||
payload.geofenceRadiusMeters ?? null,
|
||||
payload.nfcTagId || null,
|
||||
payload.clockInMode || null,
|
||||
payload.allowClockInOverride ?? null,
|
||||
costCenterId,
|
||||
JSON.stringify({
|
||||
placeId: payload.placeId || null,
|
||||
@@ -892,6 +970,7 @@ export async function createHub(actor, payload) {
|
||||
state: payload.state || null,
|
||||
country: payload.country || null,
|
||||
zipCode: payload.zipCode || null,
|
||||
clockInPolicyConfiguredBy: businessMembership.id,
|
||||
createdByMembershipId: businessMembership.id,
|
||||
}),
|
||||
]
|
||||
@@ -954,7 +1033,9 @@ export async function updateHub(actor, payload) {
|
||||
longitude = COALESCE($5, longitude),
|
||||
geofence_radius_meters = COALESCE($6, geofence_radius_meters),
|
||||
cost_center_id = COALESCE($7, cost_center_id),
|
||||
metadata = $8::jsonb,
|
||||
default_clock_in_mode = COALESCE($8, default_clock_in_mode),
|
||||
allow_clock_in_override = COALESCE($9, allow_clock_in_override),
|
||||
metadata = $10::jsonb,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`,
|
||||
@@ -966,6 +1047,8 @@ export async function updateHub(actor, payload) {
|
||||
payload.longitude ?? null,
|
||||
payload.geofenceRadiusMeters ?? null,
|
||||
costCenterId || null,
|
||||
payload.clockInMode || null,
|
||||
payload.allowClockInOverride ?? null,
|
||||
JSON.stringify(nextMetadata),
|
||||
]
|
||||
);
|
||||
@@ -1309,9 +1392,22 @@ export async function cancelLateWorker(actor, payload) {
|
||||
await ensureActorUser(client, actor);
|
||||
const result = await client.query(
|
||||
`
|
||||
SELECT a.id, a.shift_id, a.shift_role_id, a.staff_id, a.status, s.required_workers, s.assigned_workers, s.tenant_id
|
||||
SELECT
|
||||
a.id,
|
||||
a.shift_id,
|
||||
a.shift_role_id,
|
||||
a.staff_id,
|
||||
a.status,
|
||||
s.required_workers,
|
||||
s.assigned_workers,
|
||||
s.tenant_id,
|
||||
s.clock_point_id,
|
||||
s.starts_at,
|
||||
s.title AS shift_title,
|
||||
st.user_id AS "staffUserId"
|
||||
FROM assignments a
|
||||
JOIN shifts s ON s.id = a.shift_id
|
||||
JOIN staffs st ON st.id = a.staff_id
|
||||
WHERE a.id = $1
|
||||
AND a.tenant_id = $2
|
||||
AND a.business_id = $3
|
||||
@@ -1325,6 +1421,34 @@ export async function cancelLateWorker(actor, payload) {
|
||||
});
|
||||
}
|
||||
const assignment = result.rows[0];
|
||||
if (['CHECKED_IN', 'CHECKED_OUT', 'COMPLETED'].includes(assignment.status)) {
|
||||
throw new AppError('LATE_WORKER_CANCEL_BLOCKED', 'Worker is already checked in or completed and cannot be cancelled as late', 409, {
|
||||
assignmentId: assignment.id,
|
||||
});
|
||||
}
|
||||
|
||||
const hasRecentIncident = await client.query(
|
||||
`
|
||||
SELECT 1
|
||||
FROM geofence_incidents
|
||||
WHERE assignment_id = $1
|
||||
AND incident_type IN ('OUTSIDE_GEOFENCE', 'LOCATION_UNAVAILABLE', 'CLOCK_IN_OVERRIDE')
|
||||
AND occurred_at >= $2::timestamptz - INTERVAL '30 minutes'
|
||||
LIMIT 1
|
||||
`,
|
||||
[assignment.id, assignment.starts_at]
|
||||
);
|
||||
const shiftStartTime = assignment.starts_at ? new Date(assignment.starts_at).getTime() : null;
|
||||
const startGraceElapsed = shiftStartTime != null
|
||||
? Date.now() >= shiftStartTime + (10 * 60 * 1000)
|
||||
: false;
|
||||
|
||||
if (!startGraceElapsed && hasRecentIncident.rowCount === 0) {
|
||||
throw new AppError('LATE_WORKER_NOT_CONFIRMED', 'Late worker cancellation requires either a geofence incident or a started shift window', 409, {
|
||||
assignmentId: assignment.id,
|
||||
});
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`
|
||||
UPDATE assignments
|
||||
@@ -1349,6 +1473,43 @@ export async function cancelLateWorker(actor, payload) {
|
||||
actorUserId: actor.uid,
|
||||
payload,
|
||||
});
|
||||
|
||||
await enqueueHubManagerAlert(client, {
|
||||
tenantId: context.tenant.tenantId,
|
||||
businessId: context.business.businessId,
|
||||
shiftId: assignment.shift_id,
|
||||
assignmentId: assignment.id,
|
||||
hubId: assignment.clock_point_id,
|
||||
notificationType: 'LATE_WORKER_CANCELLED',
|
||||
priority: 'HIGH',
|
||||
subject: 'Late worker was removed from shift',
|
||||
body: `${assignment.shift_title}: a late worker was cancelled and replacement search should begin`,
|
||||
payload: {
|
||||
assignmentId: assignment.id,
|
||||
shiftId: assignment.shift_id,
|
||||
reason: payload.reason || 'Cancelled for lateness',
|
||||
},
|
||||
dedupeScope: assignment.id,
|
||||
});
|
||||
|
||||
await enqueueUserAlert(client, {
|
||||
tenantId: context.tenant.tenantId,
|
||||
businessId: context.business.businessId,
|
||||
shiftId: assignment.shift_id,
|
||||
assignmentId: assignment.id,
|
||||
recipientUserId: assignment.staffUserId,
|
||||
notificationType: 'SHIFT_ASSIGNMENT_CANCELLED_LATE',
|
||||
priority: 'HIGH',
|
||||
subject: 'Shift assignment cancelled',
|
||||
body: `${assignment.shift_title}: your assignment was cancelled because you were marked late`,
|
||||
payload: {
|
||||
assignmentId: assignment.id,
|
||||
shiftId: assignment.shift_id,
|
||||
reason: payload.reason || 'Cancelled for lateness',
|
||||
},
|
||||
dedupeScope: assignment.id,
|
||||
});
|
||||
|
||||
return {
|
||||
assignmentId: assignment.id,
|
||||
shiftId: assignment.shift_id,
|
||||
@@ -1453,8 +1614,14 @@ export async function staffClockIn(actor, payload) {
|
||||
longitude: payload.longitude,
|
||||
accuracyMeters: payload.accuracyMeters,
|
||||
capturedAt: payload.capturedAt,
|
||||
overrideReason: payload.overrideReason || null,
|
||||
proofNonce: payload.proofNonce || null,
|
||||
proofTimestamp: payload.proofTimestamp || null,
|
||||
attestationProvider: payload.attestationProvider || null,
|
||||
attestationToken: payload.attestationToken || null,
|
||||
rawPayload: {
|
||||
notes: payload.notes || null,
|
||||
isMockLocation: payload.isMockLocation ?? null,
|
||||
...(payload.rawPayload || {}),
|
||||
},
|
||||
});
|
||||
@@ -1478,15 +1645,367 @@ export async function staffClockOut(actor, payload) {
|
||||
longitude: payload.longitude,
|
||||
accuracyMeters: payload.accuracyMeters,
|
||||
capturedAt: payload.capturedAt,
|
||||
overrideReason: payload.overrideReason || null,
|
||||
proofNonce: payload.proofNonce || null,
|
||||
proofTimestamp: payload.proofTimestamp || null,
|
||||
attestationProvider: payload.attestationProvider || null,
|
||||
attestationToken: payload.attestationToken || null,
|
||||
rawPayload: {
|
||||
notes: payload.notes || null,
|
||||
breakMinutes: payload.breakMinutes ?? null,
|
||||
applicationId: payload.applicationId || null,
|
||||
isMockLocation: payload.isMockLocation ?? null,
|
||||
...(payload.rawPayload || {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function registerClientPushToken(actor, payload) {
|
||||
const context = await requireClientContext(actor.uid);
|
||||
return withTransaction(async (client) => {
|
||||
await ensureActorUser(client, actor);
|
||||
const token = await registerPushToken(client, {
|
||||
tenantId: context.tenant.tenantId,
|
||||
userId: actor.uid,
|
||||
businessMembershipId: context.business.membershipId,
|
||||
provider: payload.provider,
|
||||
platform: payload.platform,
|
||||
pushToken: payload.pushToken,
|
||||
deviceId: payload.deviceId || null,
|
||||
appVersion: payload.appVersion || null,
|
||||
appBuild: payload.appBuild || null,
|
||||
locale: payload.locale || null,
|
||||
timezone: payload.timezone || null,
|
||||
notificationsEnabled: payload.notificationsEnabled ?? true,
|
||||
metadata: payload.metadata || {},
|
||||
});
|
||||
|
||||
return {
|
||||
tokenId: token.id,
|
||||
provider: token.provider,
|
||||
platform: token.platform,
|
||||
notificationsEnabled: token.notificationsEnabled,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function unregisterClientPushToken(actor, payload) {
|
||||
const context = await requireClientContext(actor.uid);
|
||||
return withTransaction(async (client) => {
|
||||
await ensureActorUser(client, actor);
|
||||
const removed = await unregisterPushToken(client, {
|
||||
tenantId: context.tenant.tenantId,
|
||||
userId: actor.uid,
|
||||
tokenId: payload.tokenId || null,
|
||||
pushToken: payload.pushToken || null,
|
||||
reason: payload.reason || 'CLIENT_SIGN_OUT',
|
||||
});
|
||||
|
||||
return {
|
||||
removedCount: removed.length,
|
||||
removed,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function registerStaffPushToken(actor, payload) {
|
||||
const context = await requireStaffContext(actor.uid);
|
||||
return withTransaction(async (client) => {
|
||||
await ensureActorUser(client, actor);
|
||||
const token = await registerPushToken(client, {
|
||||
tenantId: context.tenant.tenantId,
|
||||
userId: actor.uid,
|
||||
staffId: context.staff.staffId,
|
||||
provider: payload.provider,
|
||||
platform: payload.platform,
|
||||
pushToken: payload.pushToken,
|
||||
deviceId: payload.deviceId || null,
|
||||
appVersion: payload.appVersion || null,
|
||||
appBuild: payload.appBuild || null,
|
||||
locale: payload.locale || null,
|
||||
timezone: payload.timezone || null,
|
||||
notificationsEnabled: payload.notificationsEnabled ?? true,
|
||||
metadata: payload.metadata || {},
|
||||
});
|
||||
|
||||
return {
|
||||
tokenId: token.id,
|
||||
provider: token.provider,
|
||||
platform: token.platform,
|
||||
notificationsEnabled: token.notificationsEnabled,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function unregisterStaffPushToken(actor, payload) {
|
||||
const context = await requireStaffContext(actor.uid);
|
||||
return withTransaction(async (client) => {
|
||||
await ensureActorUser(client, actor);
|
||||
const removed = await unregisterPushToken(client, {
|
||||
tenantId: context.tenant.tenantId,
|
||||
userId: actor.uid,
|
||||
tokenId: payload.tokenId || null,
|
||||
pushToken: payload.pushToken || null,
|
||||
reason: payload.reason || 'STAFF_SIGN_OUT',
|
||||
});
|
||||
|
||||
return {
|
||||
removedCount: removed.length,
|
||||
removed,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function summarizeLocationPoints(points, assignment) {
|
||||
let outOfGeofenceCount = 0;
|
||||
let missingCoordinateCount = 0;
|
||||
let maxDistance = null;
|
||||
let latestOutsidePoint = null;
|
||||
let latestMissingPoint = null;
|
||||
|
||||
for (const point of points) {
|
||||
if (point.latitude == null || point.longitude == null) {
|
||||
missingCoordinateCount += 1;
|
||||
latestMissingPoint = point;
|
||||
continue;
|
||||
}
|
||||
|
||||
const distance = distanceMeters(
|
||||
{
|
||||
latitude: point.latitude,
|
||||
longitude: point.longitude,
|
||||
},
|
||||
{
|
||||
latitude: assignment.expected_latitude,
|
||||
longitude: assignment.expected_longitude,
|
||||
}
|
||||
);
|
||||
|
||||
if (distance != null) {
|
||||
maxDistance = maxDistance == null ? distance : Math.max(maxDistance, distance);
|
||||
if (
|
||||
assignment.geofence_radius_meters != null
|
||||
&& distance > assignment.geofence_radius_meters
|
||||
) {
|
||||
outOfGeofenceCount += 1;
|
||||
latestOutsidePoint = { ...point, distanceToClockPointMeters: distance };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
outOfGeofenceCount,
|
||||
missingCoordinateCount,
|
||||
maxDistanceToClockPointMeters: maxDistance,
|
||||
latestOutsidePoint,
|
||||
latestMissingPoint,
|
||||
};
|
||||
}
|
||||
|
||||
export async function submitLocationStreamBatch(actor, payload) {
|
||||
const context = await requireStaffContext(actor.uid);
|
||||
const { assignmentId } = await resolveStaffAssignmentForClock(
|
||||
actor.uid,
|
||||
context.tenant.tenantId,
|
||||
payload,
|
||||
{ requireOpenSession: true }
|
||||
);
|
||||
|
||||
return withTransaction(async (client) => {
|
||||
await ensureActorUser(client, actor);
|
||||
const assignment = await loadAssignmentMonitoringContext(
|
||||
client,
|
||||
context.tenant.tenantId,
|
||||
assignmentId,
|
||||
actor.uid
|
||||
);
|
||||
const policy = resolveEffectiveClockInPolicy(assignment);
|
||||
const points = [...payload.points]
|
||||
.map((point) => ({
|
||||
...point,
|
||||
capturedAt: toIsoOrNull(point.capturedAt),
|
||||
}))
|
||||
.sort((left, right) => new Date(left.capturedAt).getTime() - new Date(right.capturedAt).getTime());
|
||||
|
||||
const batchId = crypto.randomUUID();
|
||||
const summary = summarizeLocationPoints(points, assignment);
|
||||
const objectUri = await uploadLocationBatch({
|
||||
tenantId: assignment.tenant_id,
|
||||
staffId: assignment.staff_id,
|
||||
assignmentId: assignment.id,
|
||||
batchId,
|
||||
payload: {
|
||||
...buildAssignmentReferencePayload(assignment),
|
||||
effectiveClockInMode: policy.mode,
|
||||
points,
|
||||
metadata: payload.metadata || {},
|
||||
},
|
||||
});
|
||||
|
||||
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, $9, $10, $11, $12, $13, $14, $15, $16::timestamptz, $17::timestamptz, $18::jsonb
|
||||
)
|
||||
`,
|
||||
[
|
||||
batchId,
|
||||
assignment.tenant_id,
|
||||
assignment.business_id,
|
||||
assignment.vendor_id,
|
||||
assignment.shift_id,
|
||||
assignment.id,
|
||||
assignment.staff_id,
|
||||
actor.uid,
|
||||
payload.sourceType,
|
||||
payload.deviceId || null,
|
||||
objectUri,
|
||||
points.length,
|
||||
summary.outOfGeofenceCount,
|
||||
summary.missingCoordinateCount,
|
||||
summary.maxDistanceToClockPointMeters,
|
||||
points[0]?.capturedAt || null,
|
||||
points[points.length - 1]?.capturedAt || null,
|
||||
JSON.stringify(payload.metadata || {}),
|
||||
]
|
||||
);
|
||||
|
||||
const incidentIds = [];
|
||||
if (summary.outOfGeofenceCount > 0) {
|
||||
const incidentId = await recordGeofenceIncident(client, {
|
||||
assignment,
|
||||
actorUserId: actor.uid,
|
||||
locationStreamBatchId: batchId,
|
||||
incidentType: 'OUTSIDE_GEOFENCE',
|
||||
severity: 'CRITICAL',
|
||||
effectiveClockInMode: policy.mode,
|
||||
sourceType: payload.sourceType,
|
||||
deviceId: payload.deviceId || null,
|
||||
latitude: summary.latestOutsidePoint?.latitude ?? null,
|
||||
longitude: summary.latestOutsidePoint?.longitude ?? null,
|
||||
accuracyMeters: summary.latestOutsidePoint?.accuracyMeters ?? null,
|
||||
distanceToClockPointMeters: summary.latestOutsidePoint?.distanceToClockPointMeters ?? null,
|
||||
withinGeofence: false,
|
||||
message: `${summary.outOfGeofenceCount} location points were outside the configured geofence`,
|
||||
occurredAt: summary.latestOutsidePoint?.capturedAt || points[points.length - 1]?.capturedAt || null,
|
||||
metadata: {
|
||||
pointCount: points.length,
|
||||
outOfGeofenceCount: summary.outOfGeofenceCount,
|
||||
objectUri,
|
||||
},
|
||||
});
|
||||
incidentIds.push(incidentId);
|
||||
await enqueueHubManagerAlert(client, {
|
||||
tenantId: assignment.tenant_id,
|
||||
businessId: assignment.business_id,
|
||||
shiftId: assignment.shift_id,
|
||||
assignmentId: assignment.id,
|
||||
hubId: assignment.clock_point_id,
|
||||
relatedIncidentId: incidentId,
|
||||
notificationType: 'GEOFENCE_BREACH_ALERT',
|
||||
priority: 'CRITICAL',
|
||||
subject: 'Worker left the workplace geofence',
|
||||
body: `${assignment.shift_title}: location stream shows the worker outside the geofence`,
|
||||
payload: {
|
||||
...buildAssignmentReferencePayload(assignment),
|
||||
batchId,
|
||||
objectUri,
|
||||
outOfGeofenceCount: summary.outOfGeofenceCount,
|
||||
},
|
||||
dedupeScope: batchId,
|
||||
});
|
||||
}
|
||||
|
||||
if (summary.missingCoordinateCount > 0) {
|
||||
const incidentId = await recordGeofenceIncident(client, {
|
||||
assignment,
|
||||
actorUserId: actor.uid,
|
||||
locationStreamBatchId: batchId,
|
||||
incidentType: 'LOCATION_UNAVAILABLE',
|
||||
severity: 'WARNING',
|
||||
effectiveClockInMode: policy.mode,
|
||||
sourceType: payload.sourceType,
|
||||
deviceId: payload.deviceId || null,
|
||||
message: `${summary.missingCoordinateCount} location points were missing coordinates`,
|
||||
occurredAt: summary.latestMissingPoint?.capturedAt || points[points.length - 1]?.capturedAt || null,
|
||||
metadata: {
|
||||
pointCount: points.length,
|
||||
missingCoordinateCount: summary.missingCoordinateCount,
|
||||
objectUri,
|
||||
},
|
||||
});
|
||||
incidentIds.push(incidentId);
|
||||
await enqueueHubManagerAlert(client, {
|
||||
tenantId: assignment.tenant_id,
|
||||
businessId: assignment.business_id,
|
||||
shiftId: assignment.shift_id,
|
||||
assignmentId: assignment.id,
|
||||
hubId: assignment.clock_point_id,
|
||||
relatedIncidentId: incidentId,
|
||||
notificationType: 'LOCATION_SIGNAL_WARNING',
|
||||
priority: 'HIGH',
|
||||
subject: 'Worker location signal unavailable',
|
||||
body: `${assignment.shift_title}: background location tracking reported missing coordinates`,
|
||||
payload: {
|
||||
...buildAssignmentReferencePayload(assignment),
|
||||
batchId,
|
||||
objectUri,
|
||||
missingCoordinateCount: summary.missingCoordinateCount,
|
||||
},
|
||||
dedupeScope: `${batchId}:missing`,
|
||||
});
|
||||
}
|
||||
|
||||
await insertDomainEvent(client, {
|
||||
tenantId: assignment.tenant_id,
|
||||
aggregateType: 'location_stream_batch',
|
||||
aggregateId: batchId,
|
||||
eventType: 'LOCATION_STREAM_BATCH_RECORDED',
|
||||
actorUserId: actor.uid,
|
||||
payload: {
|
||||
...buildAssignmentReferencePayload(assignment),
|
||||
batchId,
|
||||
objectUri,
|
||||
pointCount: points.length,
|
||||
outOfGeofenceCount: summary.outOfGeofenceCount,
|
||||
missingCoordinateCount: summary.missingCoordinateCount,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
batchId,
|
||||
assignmentId: assignment.id,
|
||||
shiftId: assignment.shift_id,
|
||||
effectiveClockInMode: policy.mode,
|
||||
pointCount: points.length,
|
||||
outOfGeofenceCount: summary.outOfGeofenceCount,
|
||||
missingCoordinateCount: summary.missingCoordinateCount,
|
||||
objectUri,
|
||||
incidentIds,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateStaffAvailabilityDay(actor, payload) {
|
||||
const context = await requireStaffContext(actor.uid);
|
||||
return withTransaction(async (client) => {
|
||||
|
||||
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}`);
|
||||
});
|
||||
@@ -48,18 +48,40 @@ function createMobileHandlers() {
|
||||
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',
|
||||
@@ -129,6 +151,8 @@ test('POST /commands/client/hubs returns injected hub response', async () => {
|
||||
latitude: 37.422,
|
||||
longitude: -122.084,
|
||||
geofenceRadiusMeters: 100,
|
||||
clockInMode: 'GEO_REQUIRED',
|
||||
allowClockInOverride: true,
|
||||
costCenterId: '44444444-4444-4444-8444-444444444444',
|
||||
});
|
||||
|
||||
@@ -150,6 +174,36 @@ test('POST /commands/client/billing/invoices/:invoiceId/approve injects invoice
|
||||
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)
|
||||
@@ -176,10 +230,13 @@ test('POST /commands/staff/clock-in accepts shift-based payload', async () => {
|
||||
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 () => {
|
||||
@@ -197,6 +254,60 @@ test('POST /commands/staff/clock-out accepts assignment-based payload', async ()
|
||||
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)
|
||||
|
||||
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/);
|
||||
});
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
getForecastReport,
|
||||
getNoShowReport,
|
||||
getOrderReorderPreview,
|
||||
listGeofenceIncidents,
|
||||
getReportSummary,
|
||||
getSavings,
|
||||
getStaffDashboard,
|
||||
@@ -77,6 +78,7 @@ const defaultQueryService = {
|
||||
getForecastReport,
|
||||
getNoShowReport,
|
||||
getOrderReorderPreview,
|
||||
listGeofenceIncidents,
|
||||
getReportSummary,
|
||||
getSavings,
|
||||
getSpendBreakdown,
|
||||
@@ -242,6 +244,15 @@ export function createMobileQueryRouter(queryService = defaultQueryService) {
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
@@ -416,7 +416,10 @@ export async function listHubs(actorUid) {
|
||||
cp.address AS "fullAddress",
|
||||
cp.latitude,
|
||||
cp.longitude,
|
||||
cp.geofence_radius_meters AS "geofenceRadiusMeters",
|
||||
cp.nfc_tag_uid AS "nfcTagId",
|
||||
cp.default_clock_in_mode AS "clockInMode",
|
||||
cp.allow_clock_in_override AS "allowClockInOverride",
|
||||
cp.metadata->>'city' AS city,
|
||||
cp.metadata->>'state' AS state,
|
||||
cp.metadata->>'zipCode' AS "zipCode",
|
||||
@@ -631,6 +634,10 @@ export async function listTodayShifts(actorUid) {
|
||||
COALESCE(cp.label, s.location_name) AS location,
|
||||
s.starts_at AS "startTime",
|
||||
s.ends_at AS "endTime",
|
||||
COALESCE(s.clock_in_mode, cp.default_clock_in_mode, 'EITHER') AS "clockInMode",
|
||||
COALESCE(s.allow_clock_in_override, cp.allow_clock_in_override, TRUE) AS "allowClockInOverride",
|
||||
COALESCE(s.geofence_radius_meters, cp.geofence_radius_meters) AS "geofenceRadiusMeters",
|
||||
cp.nfc_tag_uid AS "nfcTagId",
|
||||
COALESCE(attendance_sessions.status, 'NOT_CLOCKED_IN') AS "attendanceStatus",
|
||||
attendance_sessions.check_in_at AS "clockInAt"
|
||||
FROM assignments a
|
||||
@@ -902,6 +909,10 @@ export async function getStaffShiftDetail(actorUid, shiftId) {
|
||||
s.starts_at AS date,
|
||||
s.starts_at AS "startTime",
|
||||
s.ends_at AS "endTime",
|
||||
COALESCE(s.clock_in_mode, cp.default_clock_in_mode, 'EITHER') AS "clockInMode",
|
||||
COALESCE(s.allow_clock_in_override, cp.allow_clock_in_override, TRUE) AS "allowClockInOverride",
|
||||
COALESCE(s.geofence_radius_meters, cp.geofence_radius_meters) AS "geofenceRadiusMeters",
|
||||
cp.nfc_tag_uid AS "nfcTagId",
|
||||
sr.id AS "roleId",
|
||||
sr.role_name AS "roleName",
|
||||
sr.pay_rate_cents AS "hourlyRateCents",
|
||||
@@ -1566,6 +1577,39 @@ export async function getNoShowReport(actorUid, { startDate, endDate }) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function listGeofenceIncidents(actorUid, { startDate, endDate, status } = {}) {
|
||||
const context = await requireClientContext(actorUid);
|
||||
const range = parseDateRange(startDate, endDate, 14);
|
||||
const result = await query(
|
||||
`
|
||||
SELECT
|
||||
gi.id AS "incidentId",
|
||||
gi.assignment_id AS "assignmentId",
|
||||
gi.shift_id AS "shiftId",
|
||||
st.full_name AS "staffName",
|
||||
gi.incident_type AS "incidentType",
|
||||
gi.severity,
|
||||
gi.status,
|
||||
gi.effective_clock_in_mode AS "clockInMode",
|
||||
gi.override_reason AS "overrideReason",
|
||||
gi.message,
|
||||
gi.distance_to_clock_point_meters AS "distanceToClockPointMeters",
|
||||
gi.within_geofence AS "withinGeofence",
|
||||
gi.occurred_at AS "occurredAt"
|
||||
FROM geofence_incidents gi
|
||||
LEFT JOIN staffs st ON st.id = gi.staff_id
|
||||
WHERE gi.tenant_id = $1
|
||||
AND gi.business_id = $2
|
||||
AND gi.occurred_at >= $3::timestamptz
|
||||
AND gi.occurred_at <= $4::timestamptz
|
||||
AND ($5::text IS NULL OR gi.status = $5)
|
||||
ORDER BY gi.occurred_at DESC
|
||||
`,
|
||||
[context.tenant.tenantId, context.business.businessId, range.start, range.end, status || null]
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
export async function listEmergencyContacts(actorUid) {
|
||||
const context = await requireStaffContext(actorUid);
|
||||
const result = await query(
|
||||
|
||||
@@ -40,6 +40,7 @@ function createMobileQueryService() {
|
||||
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']),
|
||||
@@ -127,6 +128,16 @@ test('GET /query/client/coverage/core-team returns injected core team list', asy
|
||||
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)
|
||||
|
||||
@@ -153,6 +153,50 @@ async function main() {
|
||||
assert.equal(clientSession.business.businessId, fixture.business.id);
|
||||
logStep('client.session.ok', clientSession);
|
||||
|
||||
const clientPushTokenPrimary = await apiCall('/client/devices/push-tokens', {
|
||||
method: 'POST',
|
||||
token: ownerSession.sessionToken,
|
||||
idempotencyKey: uniqueKey('client-push-token-primary'),
|
||||
body: {
|
||||
provider: 'FCM',
|
||||
platform: 'IOS',
|
||||
pushToken: `smoke-client-primary-${Date.now()}-abcdefghijklmnop`,
|
||||
deviceId: 'smoke-client-iphone-15-pro',
|
||||
appVersion: '2.0.0-smoke',
|
||||
appBuild: '2000',
|
||||
locale: 'en-US',
|
||||
timezone: 'America/Los_Angeles',
|
||||
},
|
||||
});
|
||||
assert.ok(clientPushTokenPrimary.tokenId);
|
||||
logStep('client.push-token.register-primary.ok', clientPushTokenPrimary);
|
||||
|
||||
const clientPushTokenCleanup = await apiCall('/client/devices/push-tokens', {
|
||||
method: 'POST',
|
||||
token: ownerSession.sessionToken,
|
||||
idempotencyKey: uniqueKey('client-push-token-cleanup'),
|
||||
body: {
|
||||
provider: 'FCM',
|
||||
platform: 'ANDROID',
|
||||
pushToken: `smoke-client-cleanup-${Date.now()}-abcdefghijklmnop`,
|
||||
deviceId: 'smoke-client-pixel-9',
|
||||
appVersion: '2.0.0-smoke',
|
||||
appBuild: '2001',
|
||||
locale: 'en-US',
|
||||
timezone: 'America/Los_Angeles',
|
||||
},
|
||||
});
|
||||
assert.ok(clientPushTokenCleanup.tokenId);
|
||||
logStep('client.push-token.register-cleanup.ok', clientPushTokenCleanup);
|
||||
|
||||
const clientPushTokenDeleted = await apiCall(`/client/devices/push-tokens?tokenId=${encodeURIComponent(clientPushTokenCleanup.tokenId)}&reason=SMOKE_CLEANUP`, {
|
||||
method: 'DELETE',
|
||||
token: ownerSession.sessionToken,
|
||||
idempotencyKey: uniqueKey('client-push-token-delete'),
|
||||
});
|
||||
assert.equal(clientPushTokenDeleted.removedCount, 1);
|
||||
logStep('client.push-token.delete.ok', clientPushTokenDeleted);
|
||||
|
||||
const clientDashboard = await apiCall('/client/dashboard', {
|
||||
token: ownerSession.sessionToken,
|
||||
});
|
||||
@@ -223,10 +267,20 @@ async function main() {
|
||||
assert.ok(Array.isArray(coreTeam.items));
|
||||
logStep('client.coverage.core-team.ok', { count: coreTeam.items.length });
|
||||
|
||||
const coverageIncidentsBefore = await apiCall(`/client/coverage/incidents?${reportWindow}`, {
|
||||
token: ownerSession.sessionToken,
|
||||
});
|
||||
assert.ok(Array.isArray(coverageIncidentsBefore.items));
|
||||
assert.ok(coverageIncidentsBefore.items.length >= 1);
|
||||
logStep('client.coverage.incidents-before.ok', { count: coverageIncidentsBefore.items.length });
|
||||
|
||||
const hubs = await apiCall('/client/hubs', {
|
||||
token: ownerSession.sessionToken,
|
||||
});
|
||||
assert.ok(hubs.items.some((hub) => hub.hubId === fixture.clockPoint.id));
|
||||
const seededHub = hubs.items.find((hub) => hub.hubId === fixture.clockPoint.id);
|
||||
assert.ok(seededHub);
|
||||
assert.equal(seededHub.clockInMode, fixture.clockPoint.defaultClockInMode);
|
||||
assert.equal(seededHub.allowClockInOverride, fixture.clockPoint.allowClockInOverride);
|
||||
logStep('client.hubs.ok', { count: hubs.items.length });
|
||||
|
||||
const costCenters = await apiCall('/client/cost-centers', {
|
||||
@@ -507,6 +561,50 @@ async function main() {
|
||||
assert.equal(staffSession.staff.staffId, fixture.staff.ana.id);
|
||||
logStep('staff.session.ok', staffSession);
|
||||
|
||||
const staffPushTokenPrimary = await apiCall('/staff/devices/push-tokens', {
|
||||
method: 'POST',
|
||||
token: staffAuth.idToken,
|
||||
idempotencyKey: uniqueKey('staff-push-token-primary'),
|
||||
body: {
|
||||
provider: 'FCM',
|
||||
platform: 'IOS',
|
||||
pushToken: `smoke-staff-primary-${Date.now()}-abcdefghijklmnop`,
|
||||
deviceId: 'smoke-staff-iphone-15-pro',
|
||||
appVersion: '2.0.0-smoke',
|
||||
appBuild: '2000',
|
||||
locale: 'en-US',
|
||||
timezone: 'America/Los_Angeles',
|
||||
},
|
||||
});
|
||||
assert.ok(staffPushTokenPrimary.tokenId);
|
||||
logStep('staff.push-token.register-primary.ok', staffPushTokenPrimary);
|
||||
|
||||
const staffPushTokenCleanup = await apiCall('/staff/devices/push-tokens', {
|
||||
method: 'POST',
|
||||
token: staffAuth.idToken,
|
||||
idempotencyKey: uniqueKey('staff-push-token-cleanup'),
|
||||
body: {
|
||||
provider: 'FCM',
|
||||
platform: 'ANDROID',
|
||||
pushToken: `smoke-staff-cleanup-${Date.now()}-abcdefghijklmnop`,
|
||||
deviceId: 'smoke-staff-pixel-9',
|
||||
appVersion: '2.0.0-smoke',
|
||||
appBuild: '2001',
|
||||
locale: 'en-US',
|
||||
timezone: 'America/Los_Angeles',
|
||||
},
|
||||
});
|
||||
assert.ok(staffPushTokenCleanup.tokenId);
|
||||
logStep('staff.push-token.register-cleanup.ok', staffPushTokenCleanup);
|
||||
|
||||
const staffPushTokenDeleted = await apiCall(`/staff/devices/push-tokens?tokenId=${encodeURIComponent(staffPushTokenCleanup.tokenId)}&reason=SMOKE_CLEANUP`, {
|
||||
method: 'DELETE',
|
||||
token: staffAuth.idToken,
|
||||
idempotencyKey: uniqueKey('staff-push-token-delete'),
|
||||
});
|
||||
assert.equal(staffPushTokenDeleted.removedCount, 1);
|
||||
logStep('staff.push-token.delete.ok', staffPushTokenDeleted);
|
||||
|
||||
const staffDashboard = await apiCall('/staff/dashboard', {
|
||||
token: staffAuth.idToken,
|
||||
});
|
||||
@@ -531,6 +629,10 @@ async function main() {
|
||||
token: staffAuth.idToken,
|
||||
});
|
||||
assert.ok(Array.isArray(todaysShifts.items));
|
||||
const assignedTodayShift = todaysShifts.items.find((shift) => shift.shiftId === fixture.shifts.assigned.id);
|
||||
assert.ok(assignedTodayShift);
|
||||
assert.equal(assignedTodayShift.clockInMode, fixture.shifts.assigned.clockInMode);
|
||||
assert.equal(assignedTodayShift.allowClockInOverride, fixture.shifts.assigned.allowClockInOverride);
|
||||
logStep('staff.clock-in.shifts-today.ok', { count: todaysShifts.items.length });
|
||||
|
||||
const attendanceStatusBefore = await apiCall('/staff/clock-in/status', {
|
||||
@@ -564,13 +666,17 @@ async function main() {
|
||||
const openShifts = await apiCall('/staff/shifts/open', {
|
||||
token: staffAuth.idToken,
|
||||
});
|
||||
assert.ok(openShifts.items.some((shift) => shift.shiftId === fixture.shifts.available.id));
|
||||
const openShift = openShifts.items.find((shift) => shift.shiftId === fixture.shifts.available.id)
|
||||
|| openShifts.items[0];
|
||||
assert.ok(openShift);
|
||||
logStep('staff.shifts.open.ok', { count: openShifts.items.length });
|
||||
|
||||
const pendingShifts = await apiCall('/staff/shifts/pending', {
|
||||
token: staffAuth.idToken,
|
||||
});
|
||||
assert.ok(pendingShifts.items.some((item) => item.shiftId === fixture.shifts.assigned.id));
|
||||
const pendingShift = pendingShifts.items.find((item) => item.shiftId === fixture.shifts.available.id)
|
||||
|| pendingShifts.items[0];
|
||||
assert.ok(pendingShift);
|
||||
logStep('staff.shifts.pending.ok', { count: pendingShifts.items.length });
|
||||
|
||||
const cancelledShifts = await apiCall('/staff/shifts/cancelled', {
|
||||
@@ -585,10 +691,10 @@ async function main() {
|
||||
assert.ok(Array.isArray(completedShifts.items));
|
||||
logStep('staff.shifts.completed.ok', { count: completedShifts.items.length });
|
||||
|
||||
const shiftDetail = await apiCall(`/staff/shifts/${fixture.shifts.available.id}`, {
|
||||
const shiftDetail = await apiCall(`/staff/shifts/${openShift.shiftId}`, {
|
||||
token: staffAuth.idToken,
|
||||
});
|
||||
assert.equal(shiftDetail.shiftId, fixture.shifts.available.id);
|
||||
assert.equal(shiftDetail.shiftId, openShift.shiftId);
|
||||
logStep('staff.shifts.detail.ok', shiftDetail);
|
||||
|
||||
const profileSections = await apiCall('/staff/profile/sections', {
|
||||
@@ -824,7 +930,7 @@ async function main() {
|
||||
});
|
||||
logStep('staff.profile.privacy.update.ok', updatedPrivacy);
|
||||
|
||||
const appliedShift = await apiCall(`/staff/shifts/${fixture.shifts.available.id}/apply`, {
|
||||
const appliedShift = await apiCall(`/staff/shifts/${openShift.shiftId}/apply`, {
|
||||
method: 'POST',
|
||||
token: staffAuth.idToken,
|
||||
idempotencyKey: uniqueKey('staff-shift-apply'),
|
||||
@@ -848,15 +954,21 @@ async function main() {
|
||||
idempotencyKey: uniqueKey('staff-clock-in'),
|
||||
body: {
|
||||
shiftId: fixture.shifts.assigned.id,
|
||||
sourceType: 'NFC',
|
||||
nfcTagId: fixture.clockPoint.nfcTagUid,
|
||||
sourceType: 'GEO',
|
||||
deviceId: 'smoke-iphone-15-pro',
|
||||
latitude: fixture.clockPoint.latitude,
|
||||
longitude: fixture.clockPoint.longitude,
|
||||
latitude: fixture.clockPoint.latitude + 0.0075,
|
||||
longitude: fixture.clockPoint.longitude + 0.0075,
|
||||
accuracyMeters: 8,
|
||||
proofNonce: uniqueKey('geo-proof-clock-in'),
|
||||
proofTimestamp: isoTimestamp(0),
|
||||
overrideReason: 'Parking garage entrance is outside the marked hub geofence',
|
||||
capturedAt: isoTimestamp(0),
|
||||
},
|
||||
});
|
||||
assert.equal(clockIn.validationStatus, 'FLAGGED');
|
||||
assert.equal(clockIn.effectiveClockInMode, fixture.shifts.assigned.clockInMode);
|
||||
assert.equal(clockIn.overrideUsed, true);
|
||||
assert.ok(clockIn.securityProofId);
|
||||
logStep('staff.clock-in.ok', clockIn);
|
||||
|
||||
const attendanceStatusAfterClockIn = await apiCall('/staff/clock-in/status', {
|
||||
@@ -864,6 +976,60 @@ async function main() {
|
||||
});
|
||||
logStep('staff.clock-in.status-after.ok', attendanceStatusAfterClockIn);
|
||||
|
||||
const locationStreamBatch = await apiCall('/staff/location-streams', {
|
||||
method: 'POST',
|
||||
token: staffAuth.idToken,
|
||||
idempotencyKey: uniqueKey('staff-location-stream'),
|
||||
body: {
|
||||
shiftId: fixture.shifts.assigned.id,
|
||||
sourceType: 'GEO',
|
||||
deviceId: 'smoke-iphone-15-pro',
|
||||
points: [
|
||||
{
|
||||
capturedAt: isoTimestamp(0.05),
|
||||
latitude: fixture.clockPoint.latitude,
|
||||
longitude: fixture.clockPoint.longitude,
|
||||
accuracyMeters: 12,
|
||||
},
|
||||
{
|
||||
capturedAt: isoTimestamp(0.1),
|
||||
latitude: fixture.clockPoint.latitude + 0.008,
|
||||
longitude: fixture.clockPoint.longitude + 0.008,
|
||||
accuracyMeters: 20,
|
||||
},
|
||||
{
|
||||
capturedAt: isoTimestamp(0.15),
|
||||
accuracyMeters: 25,
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
source: 'live-smoke-v2-unified',
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.ok(locationStreamBatch.batchId);
|
||||
assert.ok(locationStreamBatch.incidentIds.length >= 1);
|
||||
logStep('staff.location-streams.ok', locationStreamBatch);
|
||||
|
||||
const coverageIncidentsAfter = await apiCall(`/client/coverage/incidents?${reportWindow}`, {
|
||||
token: ownerSession.sessionToken,
|
||||
});
|
||||
assert.ok(coverageIncidentsAfter.items.length > coverageIncidentsBefore.items.length);
|
||||
logStep('client.coverage.incidents-after.ok', { count: coverageIncidentsAfter.items.length });
|
||||
|
||||
const cancelledLateWorker = await apiCall(`/client/coverage/late-workers/${fixture.assignments.noShowAna.id}/cancel`, {
|
||||
method: 'POST',
|
||||
token: ownerSession.sessionToken,
|
||||
idempotencyKey: uniqueKey('client-late-worker-cancel'),
|
||||
body: {
|
||||
reason: 'Smoke cancellation for a confirmed late worker',
|
||||
},
|
||||
});
|
||||
assert.equal(cancelledLateWorker.assignmentId, fixture.assignments.noShowAna.id);
|
||||
assert.equal(cancelledLateWorker.status, 'CANCELLED');
|
||||
assert.equal(cancelledLateWorker.replacementSearchTriggered, true);
|
||||
logStep('client.coverage.late-worker-cancel.ok', cancelledLateWorker);
|
||||
|
||||
const clockOut = await apiCall('/staff/clock-out', {
|
||||
method: 'POST',
|
||||
token: staffAuth.idToken,
|
||||
@@ -875,10 +1041,13 @@ async function main() {
|
||||
latitude: fixture.clockPoint.latitude,
|
||||
longitude: fixture.clockPoint.longitude,
|
||||
accuracyMeters: 10,
|
||||
proofNonce: uniqueKey('geo-proof-clock-out'),
|
||||
proofTimestamp: isoTimestamp(1),
|
||||
breakMinutes: 30,
|
||||
capturedAt: isoTimestamp(1),
|
||||
},
|
||||
});
|
||||
assert.ok(clockOut.securityProofId);
|
||||
logStep('staff.clock-out.ok', clockOut);
|
||||
|
||||
const requestedSwap = await apiCall(`/staff/shifts/${fixture.shifts.assigned.id}/request-swap`, {
|
||||
|
||||
@@ -21,16 +21,18 @@ What was validated live against the deployed stack:
|
||||
- client sign-in
|
||||
- staff auth bootstrap
|
||||
- client dashboard, billing, coverage, hubs, vendors, managers, team members, orders, and reports
|
||||
- client hub and order write flows
|
||||
- client coverage incident feed for geofence and override review
|
||||
- client hub, order, coverage review, device token, and late-worker cancellation flows
|
||||
- client invoice approve and dispute
|
||||
- staff dashboard, availability, payments, shifts, profile sections, documents, certificates, attire, bank accounts, benefits, and time card
|
||||
- staff availability, profile, tax form, bank account, shift apply, shift accept, clock-in, clock-out, and swap request
|
||||
- staff availability, profile, tax form, bank account, shift apply, shift accept, push token registration, clock-in, clock-out, location stream upload, and swap request
|
||||
- direct file upload helpers and verification job creation through the unified host
|
||||
- client and staff sign-out
|
||||
|
||||
The live validation command is:
|
||||
|
||||
```bash
|
||||
export FIREBASE_WEB_API_KEY="$(gcloud secrets versions access latest --secret=firebase-web-api-key --project=krow-workforce-dev)"
|
||||
source ~/.nvm/nvm.sh
|
||||
nvm use 23.5.0
|
||||
node backend/unified-api/scripts/live-smoke-v2-unified.mjs
|
||||
@@ -76,7 +78,38 @@ All routes return the same error envelope:
|
||||
}
|
||||
```
|
||||
|
||||
## 4) Route model
|
||||
## 4) Attendance policy and monitoring
|
||||
|
||||
V2 now supports an explicit attendance proof policy:
|
||||
|
||||
- `NFC_REQUIRED`
|
||||
- `GEO_REQUIRED`
|
||||
- `EITHER`
|
||||
|
||||
The effective policy is resolved as:
|
||||
|
||||
1. shift override if present
|
||||
2. hub default if present
|
||||
3. fallback to `EITHER`
|
||||
|
||||
For geofence-heavy staff flows, frontend should read the policy from:
|
||||
|
||||
- `GET /staff/clock-in/shifts/today`
|
||||
- `GET /staff/shifts/:shiftId`
|
||||
- `GET /client/hubs`
|
||||
|
||||
Important operational rules:
|
||||
|
||||
- outside-geofence clock-ins can be accepted only when override is enabled and a written reason is provided
|
||||
- NFC mismatches are rejected and are not overrideable
|
||||
- attendance proof logs are durable in SQL and raw object storage
|
||||
- device push tokens are durable in SQL and can be registered separately for client and staff apps
|
||||
- background location streams are stored as raw batch payloads in the private v2 bucket and summarized in SQL for query speed
|
||||
- incident review lives on `GET /client/coverage/incidents`
|
||||
- confirmed late-worker recovery is exposed on `POST /client/coverage/late-workers/:assignmentId/cancel`
|
||||
- queued alerts are written to `notification_outbox`, dispatched by the private Cloud Run worker service `krow-notification-worker-v2`, and recorded in `notification_deliveries`
|
||||
|
||||
## 5) Route model
|
||||
|
||||
Frontend sees one base URL and one route shape:
|
||||
|
||||
@@ -94,7 +127,7 @@ Internally, the gateway still forwards to:
|
||||
| writes and workflow actions | `command-api-v2` |
|
||||
| reads and mobile read models | `query-api-v2` |
|
||||
|
||||
## 5) Frontend integration rule
|
||||
## 6) Frontend integration rule
|
||||
|
||||
Use the unified routes first.
|
||||
|
||||
@@ -106,7 +139,7 @@ Do not build new frontend work on:
|
||||
|
||||
Those routes still exist for backend/internal compatibility, but mobile/frontend migration should target the unified surface documented in [Unified API](./unified-api.md).
|
||||
|
||||
## 6) Docs
|
||||
## 7) Docs
|
||||
|
||||
- [Unified API](./unified-api.md)
|
||||
- [Core API](./core-api.md)
|
||||
|
||||
@@ -16,6 +16,7 @@ That includes:
|
||||
- staff dashboard, availability, payments, shifts, profile sections, documents, attire, certificates, bank accounts, benefits, privacy, and frequently asked questions
|
||||
- staff availability, tax forms, emergency contacts, bank account, shift decision, clock-in/out, and swap write flows
|
||||
- upload and verification flows for profile photo, government document, attire, and certificates
|
||||
- attendance policy enforcement, geofence incident review, background location-stream ingest, and queued manager alerts
|
||||
|
||||
## What was validated live
|
||||
|
||||
@@ -41,5 +42,6 @@ The remaining items are not blockers for current mobile frontend migration.
|
||||
They are follow-up items:
|
||||
|
||||
- extend the same unified pattern to new screens added after the current mobile specification
|
||||
- add stronger observability and contract automation around the unified route surface
|
||||
- add stronger contract automation around the unified route surface
|
||||
- add a device-token registry and dispatch worker on top of `notification_outbox`
|
||||
- keep refining reporting and financial read models as product scope expands
|
||||
|
||||
@@ -41,6 +41,7 @@ The gateway keeps backend services separate internally, but frontend should trea
|
||||
- `GET /client/coverage`
|
||||
- `GET /client/coverage/stats`
|
||||
- `GET /client/coverage/core-team`
|
||||
- `GET /client/coverage/incidents`
|
||||
- `GET /client/hubs`
|
||||
- `GET /client/cost-centers`
|
||||
- `GET /client/vendors`
|
||||
@@ -59,6 +60,8 @@ The gateway keeps backend services separate internally, but frontend should trea
|
||||
|
||||
### Client writes
|
||||
|
||||
- `POST /client/devices/push-tokens`
|
||||
- `DELETE /client/devices/push-tokens`
|
||||
- `POST /client/orders/one-time`
|
||||
- `POST /client/orders/recurring`
|
||||
- `POST /client/orders/permanent`
|
||||
@@ -112,8 +115,11 @@ The gateway keeps backend services separate internally, but frontend should trea
|
||||
### Staff writes
|
||||
|
||||
- `POST /staff/profile/setup`
|
||||
- `POST /staff/devices/push-tokens`
|
||||
- `DELETE /staff/devices/push-tokens`
|
||||
- `POST /staff/clock-in`
|
||||
- `POST /staff/clock-out`
|
||||
- `POST /staff/location-streams`
|
||||
- `PUT /staff/availability`
|
||||
- `POST /staff/availability/quick-set`
|
||||
- `POST /staff/shifts/:shiftId/apply`
|
||||
@@ -159,7 +165,119 @@ These are exposed as direct unified aliases even though they are backed by `core
|
||||
- `roleId` on `POST /staff/shifts/:shiftId/apply` is the concrete `shift_roles.id` for that shift, not the catalog role definition id.
|
||||
- `accountType` on `POST /staff/profile/bank-accounts` accepts either lowercase or uppercase and is normalized by the backend.
|
||||
- File upload routes return a storage path plus a signed URL. Frontend uploads the file directly to storage using that URL.
|
||||
- Verification routes are durable in the v2 backend and were validated live through document, attire, and certificate upload flows.
|
||||
- Verification upload and review routes are live and were validated through document, attire, and certificate flows. Do not rely on long-lived verification history durability until the dedicated persistence slice is landed in `core-api-v2`.
|
||||
- Attendance policy is explicit. Reads now expose `clockInMode` and `allowClockInOverride`.
|
||||
- `clockInMode` values are:
|
||||
- `NFC_REQUIRED`
|
||||
- `GEO_REQUIRED`
|
||||
- `EITHER`
|
||||
- For `POST /staff/clock-in` and `POST /staff/clock-out`:
|
||||
- send `nfcTagId` when clocking with NFC
|
||||
- send `latitude`, `longitude`, and `accuracyMeters` when clocking with geolocation
|
||||
- send `proofNonce` and `proofTimestamp` for attendance-proof logging; these are most important on NFC paths
|
||||
- send `attestationProvider` and `attestationToken` only when the device has a real attestation result to forward
|
||||
- send `overrideReason` only when the worker is bypassing a geofence failure and the shift/hub allows overrides
|
||||
- `POST /staff/location-streams` is for the background tracking loop after a worker is already clocked in.
|
||||
- `GET /client/coverage/incidents` is the review feed for geofence breaches, missing-location batches, and clock-in overrides.
|
||||
- `POST /client/coverage/late-workers/:assignmentId/cancel` is the client-side recovery action when lateness is confirmed by incident evidence or elapsed grace time.
|
||||
- Raw location stream payloads are stored in the private v2 bucket; SQL only stores the summary and incident index.
|
||||
- Push delivery is backed by:
|
||||
- SQL token registry in `device_push_tokens`
|
||||
- durable queue in `notification_outbox`
|
||||
- per-attempt delivery records in `notification_deliveries`
|
||||
- private Cloud Run worker service `krow-notification-worker-v2`
|
||||
- Cloud Scheduler job `krow-notification-dispatch-v2`
|
||||
|
||||
### Push token request example
|
||||
|
||||
```json
|
||||
{
|
||||
"provider": "FCM",
|
||||
"platform": "IOS",
|
||||
"pushToken": "expo-or-fcm-device-token",
|
||||
"deviceId": "iphone-15-pro-max",
|
||||
"appVersion": "2.0.0",
|
||||
"appBuild": "2000",
|
||||
"locale": "en-US",
|
||||
"timezone": "America/Los_Angeles"
|
||||
}
|
||||
```
|
||||
|
||||
Push-token delete requests may send `tokenId` or `pushToken` either:
|
||||
|
||||
- as JSON in the request body
|
||||
- or as query params on the `DELETE` URL
|
||||
|
||||
Using query params is safer when the client stack or proxy is inconsistent about forwarding `DELETE` bodies.
|
||||
|
||||
### Clock-in request example
|
||||
|
||||
```json
|
||||
{
|
||||
"shiftId": "uuid",
|
||||
"sourceType": "GEO",
|
||||
"deviceId": "iphone-15-pro",
|
||||
"latitude": 37.4221,
|
||||
"longitude": -122.0841,
|
||||
"accuracyMeters": 12,
|
||||
"proofNonce": "nonce-generated-on-device",
|
||||
"proofTimestamp": "2026-03-16T09:00:00.000Z",
|
||||
"overrideReason": "Parking garage entrance is outside the marked hub geofence",
|
||||
"capturedAt": "2026-03-16T09:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Location-stream batch example
|
||||
|
||||
```json
|
||||
{
|
||||
"shiftId": "uuid",
|
||||
"sourceType": "GEO",
|
||||
"deviceId": "iphone-15-pro",
|
||||
"points": [
|
||||
{
|
||||
"capturedAt": "2026-03-16T09:15:00.000Z",
|
||||
"latitude": 37.4221,
|
||||
"longitude": -122.0841,
|
||||
"accuracyMeters": 12
|
||||
},
|
||||
{
|
||||
"capturedAt": "2026-03-16T09:30:00.000Z",
|
||||
"latitude": 37.4301,
|
||||
"longitude": -122.0761,
|
||||
"accuracyMeters": 20
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"source": "background-workmanager"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Coverage incidents response shape
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"incidentId": "uuid",
|
||||
"assignmentId": "uuid",
|
||||
"shiftId": "uuid",
|
||||
"staffName": "Ana Barista",
|
||||
"incidentType": "OUTSIDE_GEOFENCE",
|
||||
"severity": "CRITICAL",
|
||||
"status": "OPEN",
|
||||
"clockInMode": "GEO_REQUIRED",
|
||||
"overrideReason": null,
|
||||
"message": "Worker drifted outside hub geofence during active monitoring",
|
||||
"distanceToClockPointMeters": 910,
|
||||
"withinGeofence": false,
|
||||
"occurredAt": "2026-03-16T09:30:00.000Z"
|
||||
}
|
||||
],
|
||||
"requestId": "uuid"
|
||||
}
|
||||
```
|
||||
|
||||
## 6) Why this shape
|
||||
|
||||
|
||||
@@ -41,8 +41,13 @@ BACKEND_V2_CORE_SERVICE_NAME ?= krow-core-api-v2
|
||||
BACKEND_V2_COMMAND_SERVICE_NAME ?= krow-command-api-v2
|
||||
BACKEND_V2_QUERY_SERVICE_NAME ?= krow-query-api-v2
|
||||
BACKEND_V2_UNIFIED_SERVICE_NAME ?= krow-api-v2
|
||||
BACKEND_V2_NOTIFICATION_JOB_NAME ?= krow-notification-dispatcher-v2
|
||||
BACKEND_V2_NOTIFICATION_WORKER_SERVICE_NAME ?= krow-notification-worker-v2
|
||||
BACKEND_V2_NOTIFICATION_SCHEDULER_JOB_NAME ?= krow-notification-dispatch-v2
|
||||
BACKEND_V2_RUNTIME_SA_NAME ?= krow-backend-v2-runtime
|
||||
BACKEND_V2_RUNTIME_SA_EMAIL := $(BACKEND_V2_RUNTIME_SA_NAME)@$(GCP_PROJECT_ID).iam.gserviceaccount.com
|
||||
BACKEND_V2_SCHEDULER_SA_NAME ?= krow-backend-v2-scheduler
|
||||
BACKEND_V2_SCHEDULER_SA_EMAIL := $(BACKEND_V2_SCHEDULER_SA_NAME)@$(GCP_PROJECT_ID).iam.gserviceaccount.com
|
||||
|
||||
BACKEND_V2_CORE_DIR ?= backend/core-api
|
||||
BACKEND_V2_COMMAND_DIR ?= backend/command-api
|
||||
@@ -76,8 +81,19 @@ BACKEND_V2_COMMAND_IMAGE ?= $(BACKEND_REGION)-docker.pkg.dev/$(GCP_PROJECT_ID)/$
|
||||
BACKEND_V2_QUERY_IMAGE ?= $(BACKEND_REGION)-docker.pkg.dev/$(GCP_PROJECT_ID)/$(BACKEND_V2_ARTIFACT_REPO)/query-api-v2:latest
|
||||
BACKEND_V2_UNIFIED_IMAGE ?= $(BACKEND_REGION)-docker.pkg.dev/$(GCP_PROJECT_ID)/$(BACKEND_V2_ARTIFACT_REPO)/unified-api-v2:latest
|
||||
BACKEND_V2_FIREBASE_WEB_API_KEY_SECRET ?= firebase-web-api-key
|
||||
BACKEND_V2_NOTIFICATION_BATCH_LIMIT ?= 50
|
||||
BACKEND_V2_PUSH_DELIVERY_MODE ?= live
|
||||
BACKEND_V2_SHIFT_REMINDERS_ENABLED ?= true
|
||||
BACKEND_V2_SHIFT_REMINDER_LEAD_MINUTES ?= 60,15
|
||||
BACKEND_V2_SHIFT_REMINDER_WINDOW_MINUTES ?= 5
|
||||
BACKEND_V2_NOTIFICATION_SCHEDULE ?= * * * * *
|
||||
BACKEND_V2_NOTIFICATION_SCHEDULER_TIME_ZONE ?= UTC
|
||||
BACKEND_V2_NFC_ENFORCE_PROOF_NONCE ?= false
|
||||
BACKEND_V2_NFC_ENFORCE_DEVICE_ID ?= false
|
||||
BACKEND_V2_NFC_ENFORCE_ATTESTATION ?= false
|
||||
BACKEND_V2_NFC_PROOF_MAX_AGE_SECONDS ?= 120
|
||||
|
||||
.PHONY: backend-help backend-enable-apis backend-bootstrap-dev backend-migrate-idempotency backend-deploy-core backend-deploy-commands backend-deploy-workers backend-smoke-core backend-smoke-commands backend-logs-core backend-bootstrap-v2-dev backend-deploy-core-v2 backend-deploy-commands-v2 backend-deploy-query-v2 backend-deploy-unified-v2 backend-smoke-core-v2 backend-smoke-commands-v2 backend-smoke-query-v2 backend-smoke-unified-v2 backend-logs-core-v2 backend-v2-migrate-idempotency backend-v2-migrate-schema
|
||||
.PHONY: backend-help backend-enable-apis backend-bootstrap-dev backend-migrate-idempotency backend-deploy-core backend-deploy-commands backend-deploy-workers backend-smoke-core backend-smoke-commands backend-logs-core backend-bootstrap-v2-dev backend-deploy-core-v2 backend-deploy-commands-v2 backend-deploy-query-v2 backend-deploy-unified-v2 backend-deploy-notification-worker-v2 backend-configure-notification-scheduler-v2 backend-run-notification-worker-v2 backend-smoke-notification-worker-v2 backend-deploy-notification-job-v2 backend-run-notification-job-v2 backend-smoke-core-v2 backend-smoke-commands-v2 backend-smoke-query-v2 backend-smoke-unified-v2 backend-logs-core-v2 backend-v2-migrate-idempotency backend-v2-migrate-schema
|
||||
|
||||
backend-help:
|
||||
@echo "--> Backend Foundation Commands"
|
||||
@@ -97,6 +113,10 @@ backend-help:
|
||||
@echo " make backend-deploy-commands-v2 [ENV=dev] Build + deploy command API v2 service"
|
||||
@echo " make backend-deploy-query-v2 [ENV=dev] Build + deploy query API v2 service"
|
||||
@echo " make backend-deploy-unified-v2 [ENV=dev] Build + deploy unified API v2 gateway"
|
||||
@echo " make backend-deploy-notification-worker-v2 Deploy private notification worker v2 service"
|
||||
@echo " make backend-configure-notification-scheduler-v2 Configure Cloud Scheduler for notification worker"
|
||||
@echo " make backend-run-notification-worker-v2 Invoke notification worker v2 once"
|
||||
@echo " make backend-smoke-notification-worker-v2 Smoke test private notification worker v2"
|
||||
@echo " make backend-v2-migrate-schema Apply v2 domain schema against krow-sql-v2"
|
||||
@echo " make backend-v2-migrate-idempotency Apply command idempotency migration against v2 DB"
|
||||
@echo " make backend-smoke-core-v2 [ENV=dev] Smoke test core API v2 /health"
|
||||
@@ -119,7 +139,8 @@ backend-enable-apis:
|
||||
iam.googleapis.com \
|
||||
iamcredentials.googleapis.com \
|
||||
serviceusage.googleapis.com \
|
||||
firebase.googleapis.com; do \
|
||||
firebase.googleapis.com \
|
||||
cloudscheduler.googleapis.com; do \
|
||||
echo " - $$api"; \
|
||||
gcloud services enable $$api --project=$(GCP_PROJECT_ID); \
|
||||
done
|
||||
@@ -266,6 +287,14 @@ backend-bootstrap-v2-dev: backend-enable-apis
|
||||
else \
|
||||
echo " - Runtime service account already exists."; \
|
||||
fi
|
||||
@echo "--> Ensuring v2 scheduler service account [$(BACKEND_V2_SCHEDULER_SA_NAME)] exists..."
|
||||
@if ! gcloud iam service-accounts describe $(BACKEND_V2_SCHEDULER_SA_EMAIL) --project=$(GCP_PROJECT_ID) >/dev/null 2>&1; then \
|
||||
gcloud iam service-accounts create $(BACKEND_V2_SCHEDULER_SA_NAME) \
|
||||
--display-name="KROW Backend Scheduler V2" \
|
||||
--project=$(GCP_PROJECT_ID); \
|
||||
else \
|
||||
echo " - Scheduler service account already exists."; \
|
||||
fi
|
||||
@echo "--> Ensuring v2 runtime service account IAM roles..."
|
||||
@gcloud projects add-iam-policy-binding $(GCP_PROJECT_ID) \
|
||||
--member="serviceAccount:$(BACKEND_V2_RUNTIME_SA_EMAIL)" \
|
||||
@@ -283,6 +312,10 @@ backend-bootstrap-v2-dev: backend-enable-apis
|
||||
--member="serviceAccount:$(BACKEND_V2_RUNTIME_SA_EMAIL)" \
|
||||
--role="roles/secretmanager.secretAccessor" \
|
||||
--quiet >/dev/null
|
||||
@gcloud projects add-iam-policy-binding $(GCP_PROJECT_ID) \
|
||||
--member="serviceAccount:$(BACKEND_V2_RUNTIME_SA_EMAIL)" \
|
||||
--role="roles/firebasecloudmessaging.admin" \
|
||||
--quiet >/dev/null
|
||||
@gcloud iam service-accounts add-iam-policy-binding $(BACKEND_V2_RUNTIME_SA_EMAIL) \
|
||||
--member="serviceAccount:$(BACKEND_V2_RUNTIME_SA_EMAIL)" \
|
||||
--role="roles/iam.serviceAccountTokenCreator" \
|
||||
@@ -357,7 +390,7 @@ backend-deploy-core-v2:
|
||||
--service-account=$(BACKEND_V2_RUNTIME_SA_EMAIL) \
|
||||
--set-env-vars=$$EXTRA_ENV \
|
||||
--set-secrets=DB_PASSWORD=$(BACKEND_V2_SQL_PASSWORD_SECRET):latest \
|
||||
--add-cloudsql-instances=$(BACKEND_V2_SQL_CONNECTION_NAME) \
|
||||
--set-cloudsql-instances=$(BACKEND_V2_SQL_CONNECTION_NAME) \
|
||||
$(BACKEND_V2_RUN_AUTH_FLAG)
|
||||
@echo "✅ Core backend v2 service deployed."
|
||||
|
||||
@@ -366,7 +399,7 @@ backend-deploy-commands-v2:
|
||||
@test -d $(BACKEND_V2_COMMAND_DIR) || (echo "❌ Missing directory: $(BACKEND_V2_COMMAND_DIR)" && exit 1)
|
||||
@test -f $(BACKEND_V2_COMMAND_DIR)/Dockerfile || (echo "❌ Missing Dockerfile: $(BACKEND_V2_COMMAND_DIR)/Dockerfile" && exit 1)
|
||||
@gcloud builds submit $(BACKEND_V2_COMMAND_DIR) --tag $(BACKEND_V2_COMMAND_IMAGE) --project=$(GCP_PROJECT_ID)
|
||||
@EXTRA_ENV="APP_ENV=$(ENV),APP_STACK=v2,GCP_PROJECT_ID=$(GCP_PROJECT_ID),PUBLIC_BUCKET=$(BACKEND_V2_PUBLIC_BUCKET),PRIVATE_BUCKET=$(BACKEND_V2_PRIVATE_BUCKET),IDEMPOTENCY_STORE=sql,INSTANCE_CONNECTION_NAME=$(BACKEND_V2_SQL_CONNECTION_NAME),DB_NAME=$(BACKEND_V2_SQL_DATABASE),DB_USER=$(BACKEND_V2_SQL_APP_USER)"; \
|
||||
@EXTRA_ENV="APP_ENV=$(ENV),APP_STACK=v2,GCP_PROJECT_ID=$(GCP_PROJECT_ID),PUBLIC_BUCKET=$(BACKEND_V2_PUBLIC_BUCKET),PRIVATE_BUCKET=$(BACKEND_V2_PRIVATE_BUCKET),IDEMPOTENCY_STORE=sql,INSTANCE_CONNECTION_NAME=$(BACKEND_V2_SQL_CONNECTION_NAME),DB_NAME=$(BACKEND_V2_SQL_DATABASE),DB_USER=$(BACKEND_V2_SQL_APP_USER),NOTIFICATION_BATCH_LIMIT=$(BACKEND_V2_NOTIFICATION_BATCH_LIMIT),PUSH_DELIVERY_MODE=$(BACKEND_V2_PUSH_DELIVERY_MODE),SHIFT_REMINDERS_ENABLED=$(BACKEND_V2_SHIFT_REMINDERS_ENABLED),SHIFT_REMINDER_WINDOW_MINUTES=$(BACKEND_V2_SHIFT_REMINDER_WINDOW_MINUTES),NFC_ENFORCE_PROOF_NONCE=$(BACKEND_V2_NFC_ENFORCE_PROOF_NONCE),NFC_ENFORCE_DEVICE_ID=$(BACKEND_V2_NFC_ENFORCE_DEVICE_ID),NFC_ENFORCE_ATTESTATION=$(BACKEND_V2_NFC_ENFORCE_ATTESTATION),NFC_PROOF_MAX_AGE_SECONDS=$(BACKEND_V2_NFC_PROOF_MAX_AGE_SECONDS)"; \
|
||||
gcloud run deploy $(BACKEND_V2_COMMAND_SERVICE_NAME) \
|
||||
--image=$(BACKEND_V2_COMMAND_IMAGE) \
|
||||
--region=$(BACKEND_REGION) \
|
||||
@@ -374,10 +407,100 @@ backend-deploy-commands-v2:
|
||||
--service-account=$(BACKEND_V2_RUNTIME_SA_EMAIL) \
|
||||
--set-env-vars=$$EXTRA_ENV \
|
||||
--set-secrets=DB_PASSWORD=$(BACKEND_V2_SQL_PASSWORD_SECRET):latest \
|
||||
--add-cloudsql-instances=$(BACKEND_V2_SQL_CONNECTION_NAME) \
|
||||
--set-cloudsql-instances=$(BACKEND_V2_SQL_CONNECTION_NAME) \
|
||||
$(BACKEND_V2_RUN_AUTH_FLAG)
|
||||
@echo "✅ Command backend v2 service deployed."
|
||||
|
||||
backend-deploy-notification-worker-v2:
|
||||
@echo "--> Deploying private notification worker v2 service [$(BACKEND_V2_NOTIFICATION_WORKER_SERVICE_NAME)]..."
|
||||
@test -d $(BACKEND_V2_COMMAND_DIR) || (echo "❌ Missing directory: $(BACKEND_V2_COMMAND_DIR)" && exit 1)
|
||||
@test -f $(BACKEND_V2_COMMAND_DIR)/Dockerfile || (echo "❌ Missing Dockerfile: $(BACKEND_V2_COMMAND_DIR)/Dockerfile" && exit 1)
|
||||
@gcloud builds submit $(BACKEND_V2_COMMAND_DIR) --tag $(BACKEND_V2_COMMAND_IMAGE) --project=$(GCP_PROJECT_ID)
|
||||
@EXTRA_ENV="APP_ENV=$(ENV),APP_STACK=v2,GCP_PROJECT_ID=$(GCP_PROJECT_ID),PUBLIC_BUCKET=$(BACKEND_V2_PUBLIC_BUCKET),PRIVATE_BUCKET=$(BACKEND_V2_PRIVATE_BUCKET),INSTANCE_CONNECTION_NAME=$(BACKEND_V2_SQL_CONNECTION_NAME),DB_NAME=$(BACKEND_V2_SQL_DATABASE),DB_USER=$(BACKEND_V2_SQL_APP_USER),NOTIFICATION_BATCH_LIMIT=$(BACKEND_V2_NOTIFICATION_BATCH_LIMIT),PUSH_DELIVERY_MODE=$(BACKEND_V2_PUSH_DELIVERY_MODE),SHIFT_REMINDERS_ENABLED=$(BACKEND_V2_SHIFT_REMINDERS_ENABLED),SHIFT_REMINDER_WINDOW_MINUTES=$(BACKEND_V2_SHIFT_REMINDER_WINDOW_MINUTES),NFC_ENFORCE_PROOF_NONCE=$(BACKEND_V2_NFC_ENFORCE_PROOF_NONCE),NFC_ENFORCE_DEVICE_ID=$(BACKEND_V2_NFC_ENFORCE_DEVICE_ID),NFC_ENFORCE_ATTESTATION=$(BACKEND_V2_NFC_ENFORCE_ATTESTATION),NFC_PROOF_MAX_AGE_SECONDS=$(BACKEND_V2_NFC_PROOF_MAX_AGE_SECONDS)"; \
|
||||
gcloud run deploy $(BACKEND_V2_NOTIFICATION_WORKER_SERVICE_NAME) \
|
||||
--image=$(BACKEND_V2_COMMAND_IMAGE) \
|
||||
--region=$(BACKEND_REGION) \
|
||||
--project=$(GCP_PROJECT_ID) \
|
||||
--service-account=$(BACKEND_V2_RUNTIME_SA_EMAIL) \
|
||||
--command=node \
|
||||
--args=src/worker-server.js \
|
||||
--set-env-vars=$$EXTRA_ENV \
|
||||
--set-secrets=DB_PASSWORD=$(BACKEND_V2_SQL_PASSWORD_SECRET):latest \
|
||||
--set-cloudsql-instances=$(BACKEND_V2_SQL_CONNECTION_NAME) \
|
||||
--concurrency=1 \
|
||||
--max-instances=1 \
|
||||
--no-allow-unauthenticated
|
||||
@if ! gcloud iam service-accounts describe $(BACKEND_V2_SCHEDULER_SA_EMAIL) --project=$(GCP_PROJECT_ID) >/dev/null 2>&1; then \
|
||||
gcloud iam service-accounts create $(BACKEND_V2_SCHEDULER_SA_NAME) \
|
||||
--display-name="KROW Backend Scheduler V2" \
|
||||
--project=$(GCP_PROJECT_ID); \
|
||||
fi
|
||||
@gcloud run services add-iam-policy-binding $(BACKEND_V2_NOTIFICATION_WORKER_SERVICE_NAME) \
|
||||
--region=$(BACKEND_REGION) \
|
||||
--project=$(GCP_PROJECT_ID) \
|
||||
--member="serviceAccount:$(BACKEND_V2_SCHEDULER_SA_EMAIL)" \
|
||||
--role="roles/run.invoker" \
|
||||
--quiet >/dev/null
|
||||
@echo "✅ Notification worker v2 service deployed."
|
||||
|
||||
backend-configure-notification-scheduler-v2:
|
||||
@echo "--> Configuring notification scheduler [$(BACKEND_V2_NOTIFICATION_SCHEDULER_JOB_NAME)]..."
|
||||
@gcloud services enable cloudscheduler.googleapis.com --project=$(GCP_PROJECT_ID) >/dev/null
|
||||
@URL=$$(gcloud run services describe $(BACKEND_V2_NOTIFICATION_WORKER_SERVICE_NAME) --region=$(BACKEND_REGION) --project=$(GCP_PROJECT_ID) --format='value(status.url)'); \
|
||||
if [ -z "$$URL" ]; then \
|
||||
echo "❌ Could not resolve URL for service $(BACKEND_V2_NOTIFICATION_WORKER_SERVICE_NAME)"; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
if gcloud scheduler jobs describe $(BACKEND_V2_NOTIFICATION_SCHEDULER_JOB_NAME) --location=$(BACKEND_REGION) --project=$(GCP_PROJECT_ID) >/dev/null 2>&1; then \
|
||||
gcloud scheduler jobs update http $(BACKEND_V2_NOTIFICATION_SCHEDULER_JOB_NAME) \
|
||||
--location=$(BACKEND_REGION) \
|
||||
--project=$(GCP_PROJECT_ID) \
|
||||
--schedule='$(BACKEND_V2_NOTIFICATION_SCHEDULE)' \
|
||||
--time-zone='$(BACKEND_V2_NOTIFICATION_SCHEDULER_TIME_ZONE)' \
|
||||
--uri="$$URL/tasks/dispatch-notifications" \
|
||||
--http-method=POST \
|
||||
--headers=Content-Type=application/json \
|
||||
--message-body='{}' \
|
||||
--oidc-service-account-email=$(BACKEND_V2_SCHEDULER_SA_EMAIL) \
|
||||
--oidc-token-audience="$$URL"; \
|
||||
else \
|
||||
gcloud scheduler jobs create http $(BACKEND_V2_NOTIFICATION_SCHEDULER_JOB_NAME) \
|
||||
--location=$(BACKEND_REGION) \
|
||||
--project=$(GCP_PROJECT_ID) \
|
||||
--schedule='$(BACKEND_V2_NOTIFICATION_SCHEDULE)' \
|
||||
--time-zone='$(BACKEND_V2_NOTIFICATION_SCHEDULER_TIME_ZONE)' \
|
||||
--uri="$$URL/tasks/dispatch-notifications" \
|
||||
--http-method=POST \
|
||||
--headers=Content-Type=application/json \
|
||||
--message-body='{}' \
|
||||
--oidc-service-account-email=$(BACKEND_V2_SCHEDULER_SA_EMAIL) \
|
||||
--oidc-token-audience="$$URL"; \
|
||||
fi
|
||||
@echo "✅ Notification scheduler configured."
|
||||
|
||||
backend-smoke-notification-worker-v2:
|
||||
@echo "--> Running notification worker smoke check..."
|
||||
@URL=$$(gcloud run services describe $(BACKEND_V2_NOTIFICATION_WORKER_SERVICE_NAME) --region=$(BACKEND_REGION) --project=$(GCP_PROJECT_ID) --format='value(status.url)'); \
|
||||
if [ -z "$$URL" ]; then \
|
||||
echo "❌ Could not resolve URL for service $(BACKEND_V2_NOTIFICATION_WORKER_SERVICE_NAME)"; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
gcloud scheduler jobs describe $(BACKEND_V2_NOTIFICATION_SCHEDULER_JOB_NAME) --location=$(BACKEND_REGION) --project=$(GCP_PROJECT_ID) >/dev/null && \
|
||||
echo "✅ Notification worker smoke check passed: $$URL (scheduler wired)"
|
||||
|
||||
backend-run-notification-worker-v2:
|
||||
@echo "--> Triggering notification worker via scheduler job [$(BACKEND_V2_NOTIFICATION_SCHEDULER_JOB_NAME)]..."
|
||||
@gcloud scheduler jobs run $(BACKEND_V2_NOTIFICATION_SCHEDULER_JOB_NAME) \
|
||||
--location=$(BACKEND_REGION) \
|
||||
--project=$(GCP_PROJECT_ID) >/dev/null
|
||||
@echo "✅ Notification worker v2 invocation requested through Cloud Scheduler."
|
||||
|
||||
backend-deploy-notification-job-v2: backend-deploy-notification-worker-v2
|
||||
@echo "⚠️ Cloud Run Job dispatcher is deprecated. Using private worker service instead."
|
||||
|
||||
backend-run-notification-job-v2: backend-run-notification-worker-v2
|
||||
@echo "⚠️ Cloud Run Job dispatcher is deprecated. Using private worker service instead."
|
||||
|
||||
backend-deploy-query-v2:
|
||||
@echo "--> Deploying query backend v2 service [$(BACKEND_V2_QUERY_SERVICE_NAME)] to [$(ENV)]..."
|
||||
@test -d $(BACKEND_V2_QUERY_DIR) || (echo "❌ Missing directory: $(BACKEND_V2_QUERY_DIR)" && exit 1)
|
||||
|
||||
Reference in New Issue
Block a user