feat(attendance): add geofence monitoring and policy controls

This commit is contained in:
zouantchaw
2026-03-16 15:31:13 +01:00
parent b455455a49
commit 5d8240ed51
22 changed files with 1667 additions and 162 deletions

View File

@@ -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"
},

View File

@@ -15,6 +15,7 @@
"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",

View File

@@ -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

View File

@@ -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',
},
},
};

View File

@@ -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;

View File

@@ -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);

View File

@@ -16,6 +16,7 @@ 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 shiftPositionSchema = z.object({
roleId: z.string().uuid().optional(),
@@ -68,6 +69,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({
@@ -203,6 +206,7 @@ export const staffClockInSchema = z.object({
accuracyMeters: z.number().int().nonnegative().optional(),
capturedAt: z.string().datetime().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',
@@ -221,12 +225,34 @@ export const staffClockOutSchema = z.object({
accuracyMeters: z.number().int().nonnegative().optional(),
capturedAt: z.string().datetime().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 staffProfileSetupSchema = z.object({
fullName: z.string().min(2).max(160),
bio: z.string().max(5000).optional(),

View File

@@ -26,6 +26,7 @@ import {
setupStaffProfile,
staffClockIn,
staffClockOut,
submitLocationStreamBatch,
submitTaxForm,
updateEmergencyContact,
updateHub,
@@ -65,6 +66,7 @@ import {
shiftDecisionSchema,
staffClockInSchema,
staffClockOutSchema,
staffLocationBatchSchema,
staffProfileSetupSchema,
taxFormDraftSchema,
taxFormSubmitSchema,
@@ -94,6 +96,7 @@ const defaultHandlers = {
setupStaffProfile,
staffClockIn,
staffClockOut,
submitLocationStreamBatch,
submitTaxForm,
updateEmergencyContact,
updateHub,
@@ -296,6 +299,13 @@ 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.put(...mobileCommand('/staff/availability', {
schema: availabilityDayUpdateSchema,
policyAction: 'staff.availability.write',

View 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;
}

View 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,
};
}

View File

@@ -1,36 +1,13 @@
import { AppError } from '../lib/errors.js';
import { withTransaction } from './db.js';
import { recordGeofenceIncident } from './attendance-monitoring.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 +156,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 +1087,38 @@ 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 validation = evaluateClockInAttempt(assignment, payload);
const capturedAt = toIsoOrNull(payload.capturedAt) || new Date().toISOString();
if (validation.validationStatus === 'REJECTED') {
const incidentType = validation.validationCode === 'NFC_MISMATCH'
? 'NFC_MISMATCH'
: 'CLOCK_IN_REJECTED';
const incidentId = await recordGeofenceIncident(client, {
assignment,
actorUserId: actor.uid,
incidentType,
severity: validation.validationCode === 'NFC_MISMATCH' ? 'CRITICAL' : '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,
message: validation.validationReason,
occurredAt: capturedAt,
metadata: {
validationCode: validation.validationCode,
eventType,
},
});
const rejectedEvent = await client.query(
`
INSERT INTO attendance_events (
@@ -1213,6 +1179,7 @@ async function createAttendanceEvent(actor, payload, eventType) {
assignmentId: assignment.id,
sourceType: payload.sourceType,
validationReason: validation.validationReason,
incidentId,
},
});
@@ -1220,6 +1187,7 @@ async function createAttendanceEvent(actor, payload, eventType) {
assignmentId: payload.assignmentId,
attendanceEventId: rejectedEvent.rows[0].id,
distanceToClockPointMeters: validation.distance,
effectiveClockInMode: validation.effectiveClockInMode,
});
}
@@ -1289,12 +1257,60 @@ 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 || {}),
]
);
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 +1376,7 @@ async function createAttendanceEvent(actor, payload, eventType) {
assignmentId: assignment.id,
sessionId,
sourceType: payload.sourceType,
validationStatus: validation.validationStatus,
},
});
@@ -1369,6 +1386,8 @@ async function createAttendanceEvent(actor, payload, eventType) {
sessionId,
status: eventType,
validationStatus: eventResult.rows[0].validation_status,
effectiveClockInMode: validation.effectiveClockInMode,
overrideUsed: validation.overrideUsed,
};
});
}

View 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}`;
}

View File

@@ -2,6 +2,10 @@ 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 {
cancelOrder as cancelOrderCommand,
clockIn as clockInCommand,
@@ -30,6 +34,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 +475,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 +601,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 +941,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 +959,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 +969,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 +1032,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 +1046,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 +1391,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 +1420,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 +1472,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,6 +1613,7 @@ export async function staffClockIn(actor, payload) {
longitude: payload.longitude,
accuracyMeters: payload.accuracyMeters,
capturedAt: payload.capturedAt,
overrideReason: payload.overrideReason || null,
rawPayload: {
notes: payload.notes || null,
...(payload.rawPayload || {}),
@@ -1478,6 +1639,7 @@ export async function staffClockOut(actor, payload) {
longitude: payload.longitude,
accuracyMeters: payload.accuracyMeters,
capturedAt: payload.capturedAt,
overrideReason: payload.overrideReason || null,
rawPayload: {
notes: payload.notes || null,
breakMinutes: payload.breakMinutes ?? null,
@@ -1487,6 +1649,256 @@ export async function staffClockOut(actor, payload) {
});
}
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) => {

View 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,
});
}

View File

@@ -60,6 +60,11 @@ function createMobileHandlers() {
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 +134,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',
});
@@ -176,6 +183,7 @@ test('POST /commands/staff/clock-in accepts shift-based payload', async () => {
sourceType: 'GEO',
latitude: 37.422,
longitude: -122.084,
overrideReason: 'GPS timed out near the hub',
});
assert.equal(res.status, 200);
@@ -197,6 +205,31 @@ 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('PUT /commands/staff/profile/tax-forms/:formType uppercases form type', async () => {
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
const res = await request(app)

View File

@@ -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);

View File

@@ -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(

View File

@@ -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)

View File

@@ -223,10 +223,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', {
@@ -531,6 +541,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 +578,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 +603,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 +842,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 +866,18 @@ 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,
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);
logStep('staff.clock-in.ok', clockIn);
const attendanceStatusAfterClockIn = await apiCall('/staff/clock-in/status', {
@@ -864,6 +885,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,

View File

@@ -21,10 +21,11 @@ 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, 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, 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
@@ -76,7 +77,36 @@ 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
- 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 manager alerts are written to `notification_outbox`; this is durable notification orchestration, not a full push delivery worker yet
## 5) Route model
Frontend sees one base URL and one route shape:
@@ -94,7 +124,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 +136,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)

View File

@@ -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

View File

@@ -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`
@@ -114,6 +115,7 @@ The gateway keeps backend services separate internally, but frontend should trea
- `POST /staff/profile/setup`
- `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 +161,87 @@ 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 `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.
### Clock-in request example
```json
{
"shiftId": "uuid",
"sourceType": "GEO",
"deviceId": "iphone-15-pro",
"latitude": 37.4221,
"longitude": -122.0841,
"accuracyMeters": 12,
"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