diff --git a/backend/command-api/Dockerfile b/backend/command-api/Dockerfile index 55a6a26b..93e1dc48 100644 --- a/backend/command-api/Dockerfile +++ b/backend/command-api/Dockerfile @@ -6,6 +6,7 @@ COPY package*.json ./ RUN npm ci --omit=dev COPY src ./src +COPY scripts ./scripts ENV PORT=8080 EXPOSE 8080 diff --git a/backend/command-api/package-lock.json b/backend/command-api/package-lock.json index 2b8f2d6c..f12cea57 100644 --- a/backend/command-api/package-lock.json +++ b/backend/command-api/package-lock.json @@ -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" }, diff --git a/backend/command-api/package.json b/backend/command-api/package.json index 7d8ce23c..bf873541 100644 --- a/backend/command-api/package.json +++ b/backend/command-api/package.json @@ -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", diff --git a/backend/command-api/scripts/dispatch-notifications.mjs b/backend/command-api/scripts/dispatch-notifications.mjs new file mode 100644 index 00000000..2ab3ff4f --- /dev/null +++ b/backend/command-api/scripts/dispatch-notifications.mjs @@ -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(); +} diff --git a/backend/command-api/scripts/seed-v2-demo-data.mjs b/backend/command-api/scripts/seed-v2-demo-data.mjs index f87bd78f..a2cd2dba 100644 --- a/backend/command-api/scripts/seed-v2-demo-data.mjs +++ b/backend/command-api/scripts/seed-v2-demo-data.mjs @@ -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 diff --git a/backend/command-api/scripts/v2-demo-fixture.mjs b/backend/command-api/scripts/v2-demo-fixture.mjs index 77f22ee5..3fd31310 100644 --- a/backend/command-api/scripts/v2-demo-fixture.mjs +++ b/backend/command-api/scripts/v2-demo-fixture.mjs @@ -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', + }, + }, }; diff --git a/backend/command-api/sql/v2/004_v2_attendance_policy_and_monitoring.sql b/backend/command-api/sql/v2/004_v2_attendance_policy_and_monitoring.sql new file mode 100644 index 00000000..8490c317 --- /dev/null +++ b/backend/command-api/sql/v2/004_v2_attendance_policy_and_monitoring.sql @@ -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; diff --git a/backend/command-api/sql/v2/005_v2_notification_outbox_dedupe_fix.sql b/backend/command-api/sql/v2/005_v2_notification_outbox_dedupe_fix.sql new file mode 100644 index 00000000..3e95709c --- /dev/null +++ b/backend/command-api/sql/v2/005_v2_notification_outbox_dedupe_fix.sql @@ -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); diff --git a/backend/command-api/sql/v2/006_v2_notification_delivery_and_attendance_security.sql b/backend/command-api/sql/v2/006_v2_notification_delivery_and_attendance_security.sql new file mode 100644 index 00000000..6e749d3f --- /dev/null +++ b/backend/command-api/sql/v2/006_v2_notification_delivery_and_attendance_security.sql @@ -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); diff --git a/backend/command-api/src/contracts/commands/mobile.js b/backend/command-api/src/contracts/commands/mobile.js index 8a1b2243..e7f65551 100644 --- a/backend/command-api/src/contracts/commands/mobile.js +++ b/backend/command-api/src/contracts/commands/mobile.js @@ -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(), diff --git a/backend/command-api/src/routes/mobile.js b/backend/command-api/src/routes/mobile.js index 39a6376b..be97e3c0 100644 --- a/backend/command-api/src/routes/mobile.js +++ b/backend/command-api/src/routes/mobile.js @@ -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', diff --git a/backend/command-api/src/services/attendance-monitoring.js b/backend/command-api/src/services/attendance-monitoring.js new file mode 100644 index 00000000..caade1f2 --- /dev/null +++ b/backend/command-api/src/services/attendance-monitoring.js @@ -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; +} diff --git a/backend/command-api/src/services/attendance-security-log-storage.js b/backend/command-api/src/services/attendance-security-log-storage.js new file mode 100644 index 00000000..06a1202c --- /dev/null +++ b/backend/command-api/src/services/attendance-security-log-storage.js @@ -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}`; +} diff --git a/backend/command-api/src/services/attendance-security.js b/backend/command-api/src/services/attendance-security.js new file mode 100644 index 00000000..f1ba8a8c --- /dev/null +++ b/backend/command-api/src/services/attendance-security.js @@ -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, + }; +} diff --git a/backend/command-api/src/services/clock-in-policy.js b/backend/command-api/src/services/clock-in-policy.js new file mode 100644 index 00000000..648741b6 --- /dev/null +++ b/backend/command-api/src/services/clock-in-policy.js @@ -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, + }; +} diff --git a/backend/command-api/src/services/command-service.js b/backend/command-api/src/services/command-service.js index 09fba8fe..a9cce39b 100644 --- a/backend/command-api/src/services/command-service.js +++ b/backend/command-api/src/services/command-service.js @@ -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, }; }); } diff --git a/backend/command-api/src/services/firebase-admin.js b/backend/command-api/src/services/firebase-admin.js new file mode 100644 index 00000000..e5260629 --- /dev/null +++ b/backend/command-api/src/services/firebase-admin.js @@ -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(); +} diff --git a/backend/command-api/src/services/firebase-auth.js b/backend/command-api/src/services/firebase-auth.js index e268d5db..6125b3e7 100644 --- a/backend/command-api/src/services/firebase-auth.js +++ b/backend/command-api/src/services/firebase-auth.js @@ -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); } diff --git a/backend/command-api/src/services/location-log-storage.js b/backend/command-api/src/services/location-log-storage.js new file mode 100644 index 00000000..4c0600b8 --- /dev/null +++ b/backend/command-api/src/services/location-log-storage.js @@ -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}`; +} diff --git a/backend/command-api/src/services/mobile-command-service.js b/backend/command-api/src/services/mobile-command-service.js index 09ffec2f..e0017cd5 100644 --- a/backend/command-api/src/services/mobile-command-service.js +++ b/backend/command-api/src/services/mobile-command-service.js @@ -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) => { diff --git a/backend/command-api/src/services/notification-device-tokens.js b/backend/command-api/src/services/notification-device-tokens.js new file mode 100644 index 00000000..3ac0da1f --- /dev/null +++ b/backend/command-api/src/services/notification-device-tokens.js @@ -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] + ); +} diff --git a/backend/command-api/src/services/notification-dispatcher.js b/backend/command-api/src/services/notification-dispatcher.js new file mode 100644 index 00000000..00fd05cf --- /dev/null +++ b/backend/command-api/src/services/notification-dispatcher.js @@ -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; +} diff --git a/backend/command-api/src/services/notification-fcm.js b/backend/command-api/src/services/notification-fcm.js new file mode 100644 index 00000000..a3e8a497 --- /dev/null +++ b/backend/command-api/src/services/notification-fcm.js @@ -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', + }; + }); + }, + }; +} diff --git a/backend/command-api/src/services/notification-outbox.js b/backend/command-api/src/services/notification-outbox.js new file mode 100644 index 00000000..bc520e57 --- /dev/null +++ b/backend/command-api/src/services/notification-outbox.js @@ -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, + }); +} diff --git a/backend/command-api/src/worker-app.js b/backend/command-api/src/worker-app.js new file mode 100644 index 00000000..8ccd96ed --- /dev/null +++ b/backend/command-api/src/worker-app.js @@ -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; +} diff --git a/backend/command-api/src/worker-server.js b/backend/command-api/src/worker-server.js new file mode 100644 index 00000000..fbff2ec4 --- /dev/null +++ b/backend/command-api/src/worker-server.js @@ -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}`); +}); diff --git a/backend/command-api/test/mobile-routes.test.js b/backend/command-api/test/mobile-routes.test.js index 3870066a..466e1b48 100644 --- a/backend/command-api/test/mobile-routes.test.js +++ b/backend/command-api/test/mobile-routes.test.js @@ -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) diff --git a/backend/command-api/test/notification-dispatcher.test.js b/backend/command-api/test/notification-dispatcher.test.js new file mode 100644 index 00000000..9f0d6d62 --- /dev/null +++ b/backend/command-api/test/notification-dispatcher.test.js @@ -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); +}); diff --git a/backend/command-api/test/notification-worker.test.js b/backend/command-api/test/notification-worker.test.js new file mode 100644 index 00000000..a4865b55 --- /dev/null +++ b/backend/command-api/test/notification-worker.test.js @@ -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/); +}); diff --git a/backend/query-api/src/routes/mobile.js b/backend/query-api/src/routes/mobile.js index 5f4ce613..402dc345 100644 --- a/backend/query-api/src/routes/mobile.js +++ b/backend/query-api/src/routes/mobile.js @@ -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); diff --git a/backend/query-api/src/services/mobile-query-service.js b/backend/query-api/src/services/mobile-query-service.js index cc1b17a7..4cbb7e53 100644 --- a/backend/query-api/src/services/mobile-query-service.js +++ b/backend/query-api/src/services/mobile-query-service.js @@ -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( diff --git a/backend/query-api/test/mobile-routes.test.js b/backend/query-api/test/mobile-routes.test.js index 92cf3ead..12c3d506 100644 --- a/backend/query-api/test/mobile-routes.test.js +++ b/backend/query-api/test/mobile-routes.test.js @@ -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) diff --git a/backend/unified-api/scripts/live-smoke-v2-unified.mjs b/backend/unified-api/scripts/live-smoke-v2-unified.mjs index 4652fd85..b6cd2402 100644 --- a/backend/unified-api/scripts/live-smoke-v2-unified.mjs +++ b/backend/unified-api/scripts/live-smoke-v2-unified.mjs @@ -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`, { diff --git a/docs/BACKEND/API_GUIDES/V2/README.md b/docs/BACKEND/API_GUIDES/V2/README.md index 2bf94573..c9964d51 100644 --- a/docs/BACKEND/API_GUIDES/V2/README.md +++ b/docs/BACKEND/API_GUIDES/V2/README.md @@ -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) diff --git a/docs/BACKEND/API_GUIDES/V2/mobile-api-gap-analysis.md b/docs/BACKEND/API_GUIDES/V2/mobile-api-gap-analysis.md index 9e559ffd..b594eb5b 100644 --- a/docs/BACKEND/API_GUIDES/V2/mobile-api-gap-analysis.md +++ b/docs/BACKEND/API_GUIDES/V2/mobile-api-gap-analysis.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 diff --git a/docs/BACKEND/API_GUIDES/V2/unified-api.md b/docs/BACKEND/API_GUIDES/V2/unified-api.md index f80ef542..079ec8ab 100644 --- a/docs/BACKEND/API_GUIDES/V2/unified-api.md +++ b/docs/BACKEND/API_GUIDES/V2/unified-api.md @@ -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 diff --git a/makefiles/backend.mk b/makefiles/backend.mk index 058ee7f8..e940d293 100644 --- a/makefiles/backend.mk +++ b/makefiles/backend.mk @@ -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)